-
Notifications
You must be signed in to change notification settings - Fork 12.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Probable bug: Functional properties don't get inferred with circular type parameter constraints like T extends M<T>
#40439
Comments
T extends M<T>
T extends M<T>
@jcalz if you could help in anyway it would be great! 🙏🏻 |
Workaround: - declare const m: <T extends M<T>>(m: T) => T
+ declare const m: <T extends M<T>>(m: Identity<T>) => T
+ type Identity<T> =
+ { [K in keyof T]:
+ IsPlainObject<T[K]> extends true
+ ? Identity<T[K]>
+ : T[K]
+ }
+
+ type IsPlainObject<T> =
+ T extends object
+ ? T extends (...a: any[]) => any
+ ? false
+ : true
+ : false I'll keep this issue open to know if this is the "official" way to deal with it or not. I mean it kinda makes sense to me why does it work but still. |
FWIW the #44700 variation makes this seem actually a bug |
Pasting in that repro for reference declare const m: <T extends M<T>>(m: T) => T
type M<Self, V = Prop<Self, "v">> =
{ v: V
, t?: (v: V) => void
}
type Prop<T, K> = K extends keyof T ? T[K] : "error"
// expected : M<{ v: number, t?: (v: number) => void }>
// actual : M<unknown>
m({
v: 1,
t: k => {}
})
// expected : M<{ v: number }>
// actual : M<{ v: number }>
m({
v: 1
}) |
What you have here is equivalent to the much simpler, and also working, declare const m: <T>(m: M<T>) => T
type M<T> = {
v: T
t?: (v: T) => void
}
// expected : M<{ v: number, t?: (v: number) => void }>
// actual : M<unknown>
m({
v: 1,
t: k => {}
})
// expected : M<{ v: number }>
// actual : M<{ v: number }>
m({
v: 1
}) If I had to wager about the confusion, it's that the default for If you have a description of |
First off I was utterly confused because in the code snippet you wrote
First to give you some context - The thing is I'm using these Though to give you a concrete problem and as minimal as I offer right now (while I think of a more minimal problem), you can refer this PR (to another xstate-like library called useStateMachine) and precisely this test case. To make it work you'd have to add a phantom property useStateMachine({
initial: "a",
states: {
a: {
_: null, // Remove this and the inference stops working
effect: parameter => {}
}
}
}) This same is the problem with xstate too. So it'd be really cool if you can take another look and see if TypeScript can do a better job of inference. Thanks a lot for your time! :) TLDR: Open this test case. Remove |
Sorry for leaving in the bad comments, good reminder for me for next time 😅 |
Still finding a minimal real world repro for the variation where you need a phantom "_", though I have one for the original problem... useStateMachine({
initial: "a",
context: { foo: 1 },
states: {
a: {
on: {
X: "b"
}
},
b: {
effect: p => {
p.context.foo === 2
// @ts-expect-error (expected error for testing)
p.context.foo === "a"
p.event.type === "X"
// @ts-expect-error (expected error for testing)
p.event.type === "Y"
}
}
}
})
declare const useStateMachine: <D extends Machine<D>>(definition: InferNarrowestObject<D>) => void
type Machine<Self> =
{ initial: keyof Get<Self, "states">
, context: Get<Self, "context">
, states:
{ [S in keyof Get<Self, "states">]:
StateNode<Self, Get<Self, ["states", S]>>
}
}
type StateNode<Machine, Self, On = Get<Self, "on">> =
{ on?:
{ [E in keyof On]: MachineStateValue<Machine>
}
, effect?:
(parameter:
{ context: MachineContext<Machine>
, event: MachineEvent<Machine>
}
) => void
}
type MachineContext<Machine> = Get<Machine, "context">
type MachineStateValue<Machine> = keyof Get<Machine, "states">
type MachineEvent<Machine> =
{ type:
{ [S in keyof Get<Machine, "states">]:
keyof Get<Machine, ["states", S, "on"]>
}[keyof Get<Machine, "states">]
}
type Get<T, P, F = undefined> =
P extends keyof T
? T[P] :
P extends [] ?
T extends undefined ? F : T :
P extends [infer K1, ...infer Kr] ?
K1 extends keyof T ?
Get<T[K1], Kr, F> :
F :
never
type InferNarrowest<T> =
T extends any
? ( T extends (...a: any[]) => any ? T :
T extends object ? InferNarrowestObject<T> :
T
)
: never
type InferNarrowestObject<T> =
{ readonly [K in keyof T]: InferNarrowest<T[K]> } Notice we need This is the workaround to be clear - declare const useStateMachine: <D extends Machine<D>>(definition: D) => void
+ declare const useStateMachine: <D extends Machine<D>>(definition: InferNarrowestObject<D>) => void Also to tell you what I mean by "custom errors" here's a hypothetical example. The requirement is that if the node to which |
Repro for the phantom "_" bug. Playground. I know you can type this in a straight forward way but that's besides the point for the above reasons. It's only the case here because this is a minimal repro, in reality the requirements are much more. // Initial issue
createMachine1({
initial: "a",
context: { foo: 1 },
states: {
a: {
entry: p => { // p is any :(
}
}
}
})
// with InferNarrowestObject workaround
createMachine2({
initial: "a",
context: { foo: 1 },
states: {
a: {
entry: p => { // p is { foo: number }
},
_: null, // tho we need this phantom property...
}
}
})
// ...doesn't work without "_"
createMachine2({
initial: "a",
context: { foo: 1 },
states: {
a: {
entry: p => {
}
}
}
})
declare const createMachine1: <D extends Machine<D>>(definition: D) => void
declare const createMachine2: <D extends Machine<D>>(definition: InferNarrowestObject<D>) => void
type Machine<Self> =
{ initial: keyof Prop<Self, "states">
, context: Prop<Self, "context">
, states:
{ [S in keyof Prop<Self, "states">]:
{ entry?: (context: Prop<Self, "context">) => void
, _?: null
}
}
}
type InferNarrowest<T> =
T extends any
? ( T extends (...a: any[]) => any ? T :
T extends object ? InferNarrowestObject<T> :
T
)
: never
type InferNarrowestObject<T> =
{ readonly [K in keyof T]: InferNarrowest<T[K]> }
type Prop<T, P> =
P extends keyof T ? T[P] : never |
Here's something somwhat real-world that can't be compiled in a "straight forward way". Playground. This is as straight forward as I can manage. // compiles
createMachine({
initial: "a",
states: {
a: {},
b: {}
}
})
// add entry and it doesn't compile
createMachine({
initial: "a",
states: {
a: {
entry: k => {} // because k doesn't get inferred
},
b: {}
}
})
// annotate entry that could have been inferred and it compiles
createMachine({
initial: "a",
states: {
a: {
entry: (k: "a") => {}
},
b: {}
}
})
declare const createMachine:
< Definition extends
{ initial: keyof Definition["states"]
, states:
{ [K in keyof Definition["states"]]:
{ entry?: (k: K) => void
}
}
}
>
(definition: Definition) =>
{ definition: Definition
} |
@RyanCavanaugh what are your thoughts on this issue? Adding a functional property toppling the inference is fishy if not buggy. Especially when parameters are explicitly typed as what were expected to be inferred, it compiles. And how likely/unlikely is that future TypeScript versions would break this workaround or in other words how reliable is it? I'm asking especially because I'll be using this workaround in 1 which is decently popular to say the least and with my types it's literally going to be the best state machine library out there in terms of types :P so I'm expecting a lot of people are going to use it |
@devanshj unless I'm missing an existing trick we could re-apply here, the example above is squarely in the realm of "need a unification-based algorithm". The difficulty here lies in the self-same mapping between the keys and callback values of This definition is possibly better depending on your use cases? type SelfStates<Keys extends string> = {[K in Keys]: { entry?: (k: K) => void } }
type Definition<Initial, States> = { initial: Initial, states: States };
declare function createMachine2<StateNames extends keyof States, States extends SelfStates<StateNames & string>>(def: Definition<StateNames, States>): Definition<StateNames, States>; |
I'm a total novice but I think that's right. But more precisely I think what is happening is (perhaps that's what you meant) is that So I think providing a
It does seem to behave better (I think I did try this) beside the fact if you have more than one states it doesn't work but can be solved if you assert But ultimately it still doesn't cover my use-case of having the access to the whole tree, it turns out the my last repro that you just solved is too minimal. Let me try to increase it's requirement to the original a tad bit... // compiles
createMachine({
initial: "a",
states: {
a: {
onNext: "#someId"
},
b: {
id: "someId" as const
}
}
})
// add entry and it doesn't compile
createMachine({
initial: "a",
states: {
a: {
onNext: "#someId",
entry: k => {} // because k doesn't get inferred
},
b: {
id: "someId" as const
}
}
})
// annotate entry that could have been inferred and it compiles
createMachine({
initial: "a",
states: {
a: {
onNext: "#someId",
entry: (k: "a") => {}
},
b: {
id: "someId" as const
}
}
})
declare const createMachine:
< Definition extends
{ initial: keyof Definition["states"]
, states:
{ [K in keyof Definition["states"]]:
{ id?: string
, onNext?:
| keyof Definition["states"]
| { [S in keyof Definition["states"]]:
Definition["states"][S] extends { id: infer Id } ? `#${Id & string}` : never
}[keyof Definition["states"]]
, entry?: (k: K) => void
}
}
}
>
(definition: Definition) =>
{ definition: Definition
} I can take this a step further and not require the user to write |
This suffers from a similar problem as #48798 . It is somewhat different though because this one here doesn't contain any reverse mapped types. m({ // type parameter becomes `unknown`
a: 1,
b: 2,
k: "a",
t: k => {} // k is inferred as `never` (instead of `"a" | "b"`)
})
|
TypeScript Version:
4.0.2
Search Terms:
Circular type parameter constraint, functional property inference, function parameter inference, function argument inference
Code:
Expected behavior:
The parameter
k
oft
should be inferred as"a" | "b"
. As propertyk
already gets inferred in Case 1 & 2, so why not thek
parameter oft
Actual behavior:
The parameter of
t
is inferred asnever
andT
in inferred asunknown
. I assume firstT
gets resolved tounknown
ast
is initially inferred as(k: any) => void
which does not satisfy the constraint ofm
.Playground
The text was updated successfully, but these errors were encountered: