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 isstring | number | null
as you'd expect. - The
item
type for the second function isstrign | 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 isstring | number | null
as we had previously. - The
item
type for the second function is alsostring | number | null
, which is not what we want - it should bestring | 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.