I often use code like in example below and was wondering if there is some smart way to type the find
results without having to do explicit type assertion.
type Foo = { type: "Foo" };
type Goo = { type: "Goo" };
type Union = Foo | Goo;
const arr: Union[] = [];
const foo = arr.find(a => a.type === "Foo") as Foo;
If the as Foo
type assertion is left out, the result is of type Union
even though it can only return type Foo
.
What's the cleanest way of fixing the type of find
to return the narrowed type in examples like these?
Edit: This problem could be also applicable to filter
and other similar methods.
Edit2: Suggested similar question's accepted answer (Way to tell TypeScript compiler Array.prototype.filter removes certain types from an array?) shows that by using type guard in the predicate for find/filter
the return value can be narrowed down.
How should this type guard function look to narrow down any discriminated union if e.g. the distinguishing string literal is always under type
key?
If you want a generator for user-defined type guard functions returning a type predicate that discriminates discriminated unions, it might look something like this:
function discriminate<K extends PropertyKey, V extends string | number | boolean>(
discriminantKey: K, discriminantValue: V
) {
return <T extends Record<K, any>>(
obj: T & Record<K, V extends T[K] ? T[K] : V>
): obj is Extract<T, Record<K, V>> =>
obj[discriminantKey] === discriminantValue;
}
If I call discriminate("type", "Foo")
, the result is a function with a signature similar to <T>(obj: T)=>obj is Extract<T, {type: "Foo"}>
. (I say it's similar because the actual return value restricts T
to just types with "type"
as a key and a value you can assign "Foo"
to.) Let's see how it works:
const foo = arr.find(discriminate("type", "Foo")); // Foo | undefined
const goos = arr.filter(discriminate("type", "Goo")); // Goo[]
Looks good. And here's what happens if you pass inapplicable field/values:
const mistake1 = arr.find(discriminate("hype", "Foo")); // error!
// -------------------> ~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Union is not assignable to Record<"hype", any>.
const mistake2 = arr.find(discriminate("type", "Hoo")); // error!
// -------------------> ~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Union is not assignable to ((Foo | Goo) & Record<"type", "Hoo">)
Okay, hope that helps; good luck!
Link to code
Here is the jcalz's code from answer above extended with the negation and union.
export function isDiscriminate<K extends PropertyKey, V extends string | number | boolean>(
discriminantKey: K, discriminantValue: V | V[]
) {
return <T extends Record<K, any>>(
obj: T & Record<K, V extends T[K] ? T[K] : V>
): obj is Extract<T, Record<K, V>> =>
Array.isArray(discriminantValue)
? discriminantValue.some(v => obj[discriminantKey] === v)
: obj[discriminantKey] === discriminantValue;
}
export function isNotDiscriminate<K extends PropertyKey, V extends string | number | boolean>(
discriminantKey: K, discriminantValue: V | V[]
) {
return <T extends Record<K, any>>(
obj: T & Record<K, V extends T[K] ? T[K] : V>
): obj is Exclude<T, Record<K, V>> =>
Array.isArray(discriminantValue)
? discriminantValue.some(v => obj[discriminantKey] === v)
: obj[discriminantKey] === discriminantValue;
}
And usage:
type A = { type: "A" };
type B = { type: "B" };
type C = { type: "C" };
type Union = A | B | C;
const arr: Union[] = [];
arr.find(isDiscriminate("type", "A")); // A
arr.find(isDiscriminate("type", ["A", "B"])); // A | B
arr.find(isNotDiscriminate("type", "A")); // B | C
arr.find(isNotDiscriminate("type", ["A", "B"])) // C
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With