Skip to content
This repository has been archived by the owner on Jul 16, 2024. It is now read-only.

Struct.evolve forced type signature makes for an invalid transformation object #376

Open
kristiannotari opened this issue Jun 21, 2023 · 11 comments

Comments

@kristiannotari
Copy link

kristiannotari commented Jun 21, 2023

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.

@gcanti
Copy link
Contributor

gcanti commented Jun 21, 2023

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

@kristiannotari
Copy link
Author

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

@gcanti
Copy link
Contributor

gcanti commented Jun 21, 2023

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.

@kristiannotari
Copy link
Author

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

@kristiannotari
Copy link
Author

kristiannotari commented Jun 23, 2023

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 ✅

@jessekelly881
Copy link
Contributor

jessekelly881 commented Jun 23, 2023

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

@kristiannotari
Copy link
Author

kristiannotari commented Jun 24, 2023

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.

@kristiannotari
Copy link
Author

any news on this?

@mikearnaldi
Copy link
Member

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

@kristiannotari
Copy link
Author

kristiannotari commented Aug 9, 2023

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.

@kristiannotari
Copy link
Author

kristiannotari commented Aug 9, 2023

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.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants