TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

`silentNeverType` leak through return type inference

Open Andarist opened this issue 1 month ago β€’ 2 comments

πŸ”Ž 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

Andarist avatar Dec 02 '25 20:12 Andarist

Is there a simpler test a mere mortal like me can understand?

RyanCavanaugh avatar Dec 03 '25 18:12 RyanCavanaugh

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):

  1. keyof silentNeverType // string | number | symbol
  2. keyof silentNeverType & string // string
  3. silentNeverType[K] // silentNeverType
  4. { [K in keyof silentNeverType & string]: silentNeverType[K]; } // { [k: string]: silentNeverType }

Andarist avatar Dec 03 '25 19:12 Andarist