Skip to content

Instantly share code, notes, and snippets.

@JakeSidSmith
Last active March 4, 2021 09:52
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save JakeSidSmith/aff455d5d453fb97ceb8b6fdcb3c12af to your computer and use it in GitHub Desktop.
Save JakeSidSmith/aff455d5d453fb97ceb8b6fdcb3c12af to your computer and use it in GitHub Desktop.
TypeScript: inference of named arguments differs from positional arguments

TypeScript version: 4.2.2

tsconfig.json

{
  "compilerOptions": {
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noUncheckedIndexedAccess": true,
    "noEmit": true,
    "allowSyntheticDefaultImports": false,
    "esModuleInterop": false,
    "target": "ES5",
    "module": "CommonJS",
    "moduleResolution": "Node",
    "jsx": "react"
  }
}

I am attempting to write some types for a library that currently has none and is no longer maintained (for a legacy project that is being updated).

The library in question is a React component. As part of the components props (essentially named arguments) it takes several functions to filter items. The latter of these functions should receive a narrowed type of the items that it was passed (due to the former filter), but this does not appear to be able to be inferred.

First note that I have this type for inferring the predicate type of the filter callbacks:

type ReturnsSpecificPredicate<R> = (item: any) => item is R;

Which I can use in the following manner:

T extends ReturnsSpecificPredicate<infer R> ? R : fallback

Here is a simplified example of the API using positional arguments:

function filterTwice<
  T,
  F1 extends (item: T) => boolean,
  F2 extends (
    item: F1 extends ReturnsSpecificPredicate<infer R> ? R : T
  ) => boolean
>(
  items: readonly T[],
  fn1: F1,
  fn2: F2
): F2 extends ReturnsSpecificPredicate<infer R> ? readonly R[] : readonly T[];
function filterTwice<T>(
  items: readonly T[],
  fn1: (item: T) => boolean,
  fn2: (item: T) => boolean
) {
  return items.filter(fn1).filter(fn2);
}

const result = filterTwice(
  [1, 'a', null],
  (item): item is string | number =>
    typeof item === 'string' || typeof item === 'number',
  (item): item is string => typeof item === 'string'
);
  • The return type of this function is readonly string[] as you'd expect.
  • The item type for the first function is string | number | null as you'd expect.
  • The item type for the second function is strign | number as you'd expect.

What happens if you convert this to a config object?

function filterTwiceConfig<
  T,
  F1 extends (item: T) => boolean,
  F2 extends (
    item: F1 extends ReturnsSpecificPredicate<infer R> ? R : T
  ) => boolean
>(config: {
  items: readonly T[];
  fn1: F1;
  fn2: F2;
}): F2 extends ReturnsSpecificPredicate<infer R> ? readonly R[] : readonly T[];
function filterTwiceConfig<T>(config: {
  items: readonly T[];
  fn1: (item: T) => boolean;
  fn2: (item: T) => boolean;
}) {
  return config.items.filter(config.fn1).filter(config.fn2);
}

const result = filterTwiceConfig({
  items: [1, 'a', null],
  fn1: (item): item is string | number =>
    typeof item === 'string' || typeof item === 'number',
  fn2: (item): item is string => typeof item === 'string',
});
  • The return type of this function is readonly string[] as we had previously.
  • The item type for the first function is string | number | null as we had previously.
  • The item type for the second function is also string | number | null, which is not what we want - it should be string | number.

The strange thing about this is that explicitly defining the types for the first function allows the correct inference of the second function's item type.

const result = filterTwiceConfig({
  items: [1, 'a', null],
  fn1: (item: string | number | null): item is string | number =>
    typeof item === 'string' || typeof item === 'number',
  fn2: (item): item is string => typeof item === 'string',
});

I don't undserstand why explicitly providing these types would make any difference, as T should be equal to string | number | null anyway.

Interestingly it also will not allow types that don't intersect with what it can infer when defining the item type explicitly, which implies that it can correctly infer the item type.

So this works if I explicitly define all the types, but surely it should have worked purely from inference?

The unfortunate thing about the library I'm working with is that it has further callbacks (and other keys) that rely on the second filtering, and so exlpicitly defining the types for all the parameters and keeping these in sync with what can be inferred is not ideal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment