data
data copied to clipboard
Struct.evolve forced type signature makes for an invalid transformation object
version: 0.12.8
You can force an invalid type signature to Struct.evolve by explicitly annotating its generics during usage.
import * as Struct from "@effect/data/Struct"
const mystruct = { a: 42, b: "hello" }
const good = Struct.evolve(mystruct, { a: `not a function` }) // error ✅
const bad = Struct.evolve<typeof mystruct, Record<string, string>>(mystruct, { a: `not a function` }) // not an error ❌
In the example, the "good" row is correctly reporting an error, while the "bad" one isn't while it should.
This makes the second call to Struct.evolve fail at runtime cause it's expecting a function to call and instead if finds not a function.
I can only reproduce it by forcing its generics, but it first happened to me without forcing them, in a complex scenario when I used a generic key to create a transformation object on the fly. The type for the values of the transformation object doesn't seem relevant, as long as it's (somehow) compatible with the to be evolved struct.
I can only reproduce it by forcing its generics
There's no error because Record<string, string> is a valid Partial<typeof mystruct>:
type A = Partial<{
a: (a: number) => unknown
}>
declare const x: Record<string, string>
const a: A = x
It is not, practically speaking, because it lets you pass non functions as values, which breaks it at runtime with no compile time error. Not sure about what’s the best solution would be, but surely it’s not, at least for the properties of the original struct, they should be forced to be typed as functions
Well I'm just explaining why, with the current typings, it can't report an error. Practically speaking you are not supposed to force the types at the call site I guess, but maybe @jessekelly881 can cime in.
the dirtiest solution I see is to check the second argument type with a CheckTransformationObject<T>, but that's me not knowing everything about how it should be handled properly
I tried this approach and it seems to work. Haven't tested too many cases, feel free to roast it :)
import * as Struct from "@effect/data/Struct"
type Transformed<O, T> = unknown & {
[K in keyof O]: K extends keyof T ? (T[K] extends (...a: any) => any ? ReturnType<T[K]> : O[K]) : O[K]
}
type PartialTransform<O, T> = {
[K in keyof T]: T[K] extends (a: O[K & keyof O]) => any ? T[K] : (a: O[K & keyof O]) => unknown
}
export const evolve: {
<O, T>(t: PartialTransform<O, T>): (obj: O) => Transformed<O, T>
<O, T>(obj: O, t: PartialTransform<O, T>): Transformed<O, T>
} = Struct.evolve
const mystruct = { a: 42, b: `hello` }
const correct = evolve(mystruct, { a: () => 42 }) // compile ✅
const goodCatch = evolve(mystruct, { a: `not a function` }) // error ✅
const solved = evolve<typeof mystruct, Record<string, string>>(mystruct, { a: `not a function` }) // error ✅
const stillPossible = evolve<typeof mystruct, Record<string, () => number>>(mystruct, { a: () => 42, b: () => 34 }) // compile ✅
I will try to test this when I'm back home in a few days(currently traveling without a computer) but this solution looks correct at a glance. I do agree that the type passed to the generic should be restricted if it can be but like @gcanti mentioned, I don't know why you would ever call it this way. :P
As said before, I can't reproduce it now, I can only by fixing the type in the function call to a type you'd never manually write in such place. But I actually discovered it at runtime without an explicit annotation, so it actually happened once by itself.
It crashed saying that t[k] is not a function cause I was effectively passing { a: something which wasn't a function } while compiling just fine. I don't have that code right now to show you but it happened. Anyways it could occur that for other mistakes you end up having a generic object which results in Record<string, string> or some other troublesome case.
Btw I was doing something by creating a partial transformation object with a dynamic key, so the type got widened a bit and resulted in no error. Again, I have no example to show, but it surely is reproducible.
any news on this?
any news on this?
"Again, I have no example to show, but it surely is reproducible."
With a concrete example we may consider guarding against but as far as I can see evolve works fine with structures which is the intended usage
The subtle problem is that I too had structures, then they somehow (I typed them wrongly in another place, changed some mapping functions in between or similar) got to a Record<string, string> type and it was accepted by the .evolve combinator which obviously then threw at runtime with no compile time warning. Remember this has to do with the evolution struct, not the to evolve struct.
This is a good enough example I guess.
import * as Record from "@effect/data/ReadonlyRecord"
import * as Struct from "@effect/data/Struct"
const mystruct = { a: 42, b: `hello` }
const bad = Struct.evolve(mystruct, Record.fromEntries([[`a`, `not a function`]])) // not an error ❌
This correctly fails at compile time with the suggested fix, but again, I'm not 100% sure about the fix itself, it may have flaws, just wanted to point out this could happen quite easily, even by mistake.