Reputation: 9013
I am interested if anyone has found any good type utilities which can produce a strongly typed map/reduce. For instance, if I have the following array:
const arr = [
{ id: 1, value: "foobar" },
{ id: 2, value: () => "hello world" }
] as const;
Let's say I wanted the following:
For the runtime this is extremely simple use of the built-in filter()
that is built into the Array prototype:
const reduced = arr.filter(i => typeof i.value === "string");
But the type which comes back from this is unchanged from before. What I want is to have the type system respond to the reduced array as well. In order to do this I have a function isString
which tests at run time on whether a given input is a string but also returns the boolean literal too so that the true/false values coming back are inferable by the type system.
For reference, the isString
utility looks like this:
type IsString<T> = T extends string ? true : false;
function isString<T>(i: T) {
return (typeof i === "string") as IsString<T>;
}
This utility works for both type and run-time systems and therefore there was a naive hope that the following would work:
const reduced2 = arr.filter(i => isString(i));
Unfortunately this still just returns the same union type. How does one get this to work?
Note: I'd love to do other map-reduce functions as well but the filter operation is the most urgent for me.
Upvotes: 5
Views: 513
Reputation: 901
Using the Array#filter
call signature from TypeScript library...
interface Array<T> {
filter<S extends T>(
predicate: (value: T, index: number, array: readonly T[]) => value is S,
thisArg?: any
): S[];
}
...you can define the return type of the predicate like this to extract only the needed type.
const arr = [
{ id: 1, value: 'foobar' },
{ id: 2, value: () => 'hello world' },
] as const;
const reduced = arr.filter(
(item): item is Extract<typeof arr[number], { value: string }> =>
typeof item.value === 'string'
); // has type { readonly id: 1; readonly value: 'foobar' }[]
Note the typeof arr[number]
type; its type is { id: 1, value: 'foobar' } | { id: 2, value: () => string }
.
Upvotes: 0
Reputation: 328262
There is a call signature for the ReadonlyArray
filter()
method in the TypeScript standard library of the form
interface Array<T> {
filter<S extends T>(
predicate: (value: T, index: number, array: readonly T[]) => value is S,
thisArg?: any
): S[];
}
which means that if you pass in a predicate
callback that the compiler understands to be a user-defined type guard function; i.e., one that returns a type predicate, then the return type of filter()
can be a narrower array type than the original array.
This is the only approach I can think of which would possibly work.
One wrinkle is that the compiler will not be able to infer that i => typeof i.value === "string"
is such a type guard function. Or, more generally, the compiler does not infer type predicate return types from the body of any function implementation. If you want a function to be a type guard function, you need to manually annotate it as such.
There is a feature request at microsoft/TypeScript#38390 asking for some support for automatic inference of type guard functions, and maybe if and when that ever gets implemented, then i => typeof i.value === "string"
will magically do what you want. But for now anyway we need to give up on that.
One possible way to proceed then looks like this:
const hasStringValue = <T extends { value: any }>(
i: T): i is Extract<T, { value: string }> => typeof i.value === "string"
The function hasStringValue
is a generic type guard function which takes an input i
of a type T
constrained to {value: any}
, and returns a boolean
value which can be used to narrow i
to type Extract<T, {value: string}>
if true
. I'm using the Extract
utility type because we expect T
to be a union of types, some of which are assignable to {value: string}
.
Let's try it out:
/* const arr: readonly [{
readonly id: 1;
readonly value: "foobar";
}, {
readonly id: 2;
readonly value: () => string;
}] */
const reduced = arr.filter(hasStringValue);
/* const reduced: {
readonly id: 1;
readonly value: "foobar";
}[] */
console.log(
reduced.map(i => i.value.toUpperCase())
) // ["FOOBAR"]
Looks good. The original array arr
has a union of element types, and the output of filter()
is a new array reduced
whose element type is only the one corresponding to id: 1
.
Because we kind of did this "by hand", there are caveats. First, the compiler does not know how to check that you implemented your user defined type guard function correctly; see microsoft/TypeScript#29980. So nothing would stop you from doing this:
const hasStringValue = <T extends { value: any }>(
i: T): i is Extract<T, { value: string }> => typeof i.value !== "string"
// oops ----------------------------------------------------> ~~~
which would lead to bad things at runtime without the compiler warning you:
const reduced = arr.filter(hasStringValue);
console.log(reduced.map(i => i.value.toUpperCase())) // no compiler warning, but
// 💥 RUNTIME ERROR! i.value.toUpperCase is not a function
This can only be fixed by being careful.
Second, it's also possible that your array elements are of a type T
where Extract<T, {value: any}>
keeps or eliminates types you don't want it to. For example, maybe value
is wider than string:
const hmm = [{ value: Math.random() < 0.5 ? "hello" : 123 }];
hmm.filter(hasStringValue) // never[]
Oops, Extract<{value: string | number}, {value: string}>
is never
, because the left side is not assignable to the right side.
This could potentially be fixed by changing hasStringValue
to do more complicated things, such as checking for potential overlap instead of subtyping. I'm not going to go off on a tangent here to do that, especially because other T
types might have other corner cases to worry about. The point is that you need to figure out what sort of type predicate will actually work on your input types, so you'll have to test.
So, uh, be careful.
Upvotes: 3