`silentNeverType` leak through return type inference
π Search Terms
silent never leak reverse mapped types nested calls return type inference
π Version & Regression Information
- This is the behavior in every version I tried
β― Playground Link
https://www.typescriptlang.org/play/?noEmit=true&ts=6.0.0-dev.20251202#code/C4TwDgpgBAaghgGwK4QM4B4AqA+KBeKTAbQGsIQB7AM0IF0BuAKEdEigFk4BjACwEsAdhADCFAcAgAPYPigAlCFwoAnACbpUwZYIDmAGihwBIbE0aCJyqt2gAFOMrgBbCJb4AvCKoDyAIwBWijIA3oxQUKwQAFxQmtoCOkzhYA7OqAD8MUgCJAIUAO4CTAC+zJFQAIJcwHxiAGLZ1bUC6GGEouJSMl0QAqqoHNz8Qh0S0nptmPaOTgM9fQPTzq4Q2p4+AUFEAEQpM6jbtFAAPlDZqhBUgl4T4ZhVNWJQ8-1QSy5u636B1RO4BKFwgAKaqSGKYUZdAx7NLg96oACUMQAbhQ+KoklAAPoUJDALH3JpiTKEB7NEpmcqYCjvFZrLzfIKtO60z5eThgZ7SXqvBRKNTM8KxLS6W5C1mrDwMzbVHYw2aHE5nPqXa6qNrYP6yeDINDoQFQIgAaSggigZEoNCmqQ+kvWHKgADJhfEdLQYgbwpEYkbMckbag4Ta6VLVBzjQw2sUSqYyuBoKIEAggmSxBhJpDpFyJAtBrxrpngGLrftszzFsG2RsfsA5QHFadzqqhKotQQgW1PVAlJ1xm1wr0AI4oFBi4oe-vdsRjYDgwt+54CYcQFAxIHcR4CNcI-C4VHond4PdojFRsXywOEeETQ-H9FmC5cBAOaBURqbxfLlCpgTpu6Fstc04fMRmnKFJnhQDXglelqy2XZ6yORsVSuFtix-KCKxmEMvhlGQCBg0NGV+RhsA7cIlCTIIYkTZNqh-DAITA8YrwDTVGCRSoiQEBoBG4rBCwMEs0iEn9Y0YR9n2UV932aWJXCQMBBSY3tum5IChgLZii0mBjMPkRQVHUSc4lFSdCNwms632BtlQuVCvA1WRgmKP4gQNSJUDFDdmi8xhxygDz4wyD0p1UkkVJnKBozaHy0xJLtjVNARzXIahSW41B3S4zdeP4yd-20sUhQyzdUAjYqhWpCzpRrLAGOwSdY3CGLik4-dT0YVAFLAdzYsyicStUCgAGVgCQKgqBiIcRwgBigSBLFoQDGIBCQJxfFWW9ArascJjasxuvG3qDTi39BqFYaxomqbP1m+bFuW-ZVvWzblG2lyEUq4bvGAHhVmuya1yWqALxiUyEg+1yz38hFDp6vrwjOy8uyu8agbulc5syhaQbBqA1o2rbdx2r7Jx+v6AfR27FpiIwQCe2EXV0KG9thpggA
π» Code
type Values<T> = T[keyof T];
type MachineContext = Record<string, any>;
interface ParameterizedObject {
type: string;
params?: unknown;
}
type ActionFunction<
TContext extends MachineContext,
TParams extends ParameterizedObject["params"] | undefined,
TAction extends ParameterizedObject,
> = {
(ctx: TContext, params: TParams): void;
_out_TAction?: TAction;
};
type ToParameterizedObject<
TParameterizedMap extends Record<
string,
ParameterizedObject["params"] | undefined
>,
> = Values<{
[K in keyof TParameterizedMap & string]: {
type: K;
params: TParameterizedMap[K];
};
}>;
type CollectActions<
TContext extends MachineContext,
TParams extends ParameterizedObject["params"] | undefined,
> = (
{
context,
enqueue,
}: {
context: TContext;
enqueue: (action: () => void) => void;
},
params: TParams,
) => void;
declare function enqueueActions<
TContext extends MachineContext,
TParams extends ParameterizedObject["params"] | undefined,
TAction extends ParameterizedObject = ParameterizedObject,
>(
collect: CollectActions<TContext, TParams>,
): ActionFunction<TContext, TParams, TAction>;
declare function setup<
TContext extends MachineContext,
TActions extends Record<
string,
ParameterizedObject["params"] | undefined
> = {},
>({
types,
actions,
}: {
types?: { context?: TContext };
actions?: {
[K in keyof TActions]: ActionFunction<
TContext,
TActions[K],
ToParameterizedObject<TActions>
>;
};
}): void;
setup({
actions: {
doStuff: enqueueActions((_, params: number) => {}),
},
});
setup({
actions: {
doStuff: enqueueActions((_, params: number) => {}),
doOtherStuff: (_, params: string) => {},
},
});
setup({
actions: {
doStuff: enqueueActions((_, params: number) => {}),
doOtherStuff: (_: any, params: string) => {},
},
});
π Actual behavior
First 2 calls mention such a relationship check failure in the error message:
Type 'ActionFunction<MachineContext, number, { type: string; params: never; }>' is not assignable to type 'ActionFunction<MachineContext, number, { type: "doStuff"; params: number; }>'.
Notice never there. This is quite weird an unexpected as there is no never in sight here. It turns out this is a silentNeverType that leaked through.
π Expected behavior
At the very least, I would expect it to error with consistent error messages mentioning unknown instead of never. Even better, if it could behave closer to a very similar version of this code: TS playground. In there the third error still errors but the first two infers nicely.
Additional information about the issue
No response
Is there a simpler test a mere mortal like me can understand?
Come on, Ryan, I know you know I know youβre not a mere mortal.
A shorter repro would be this:
type Values<T> = T[keyof T];
interface ParameterizedObject {
type: string;
params?: unknown;
}
type ActionFunction<TParams, TAction extends ParameterizedObject> = {
(params: TParams): void;
_out_TAction?: TAction;
};
type ToParameterizedObject<TParameterizedMap> = Values<{
[K in keyof TParameterizedMap & string]: {
type: K;
params: TParameterizedMap[K];
};
}>;
declare function enqueueActions<TParams, TAction extends ParameterizedObject>(
collect: (params: TParams) => void,
): ActionFunction<TParams, TAction>;
declare function setup<TActions>(actions: {
[K in keyof TActions]: ActionFunction<
TActions[K],
ToParameterizedObject<TActions>
>;
}): void;
setup({
doStuff: enqueueActions((params: number) => {}),
});
And a minimal (but totally non-sensical) would be something like this:
type Fn<T> = (arg: T) => void;
declare function fn1<T>(): Fn<T>;
declare function fn2<T>(
ac: Fn<{
[K in keyof T & string]: T[K];
}>,
): void;
fn2(fn1());
The key here is that the inner call gets silentNeverType in its return type inference pass from the outer mapper as at that time there is no inference candidate for T. Then keyof silentNeverType manages to "transform" it (so it manages to "escape") and boxes it in another type (the result of this mapped type):
-
keyof silentNeverType // string | number | symbol -
keyof silentNeverType & string // string -
silentNeverType[K] // silentNeverType -
{ [K in keyof silentNeverType & string]: silentNeverType[K]; } // { [k: string]: silentNeverType }