I am trying to gain a better understanding of the extends
keyword in TypeScript and its potential applications.
One thing I have come across are two built-in utilities, Extract
and Exclude
that leverage both extends
and Conditional Typing.
/**
* Exclude from T those types that are assignable to U
*/
type Exclude<T, U> = T extends U ? never : T;
/**
* Extract from T those types that are assignable to U
*/
type Extract<T, U> = T extends U ? T : never;
I was playing around to better understand how this "narrowing down", or to better say "subset filtering" works, and have tried creating my own implementation just to see it in action, and have come across this really odd behaviour:
Link to the Playground Example:
type ValueSet = string | 'lol' | 0 | {a: 1} | number[] | 643;
type CustomExclude<T, U> = T extends U ? T : never;
// this works:
// type Result1 = 0 | 643
type Result1 = CustomExclude<ValueSet, number>;
// but this doesn't?
// type Result2 = never
type Result2 = ValueSet extends number ? ValueSet : never;
Why does that happen?
I would expect both instances to return the correct subset of the type, but the conditional typing only works if express through Generics.
Can someone explain me the logic behind this?
CodePudding user response:
That second piece of code is doing a single check to see whether the entire type extends from number. If it does, it returns the entire type, otherwise it returns never
. The version with generics is going to step through all the individual types in the union (first string
, then "lol"
, then 0
etc) and evaluate them individually. Then you get a union of whichever individual types survived.
It is possible to get a non-never value out of your second example, but only if every possible value is a number. For example:
type Example = 1 | 3 | 5;
type Example2 = Example extends number ? Example : never;
// Example2 is 1 | 3 | 5
CodePudding user response:
Please see distributive-conditional-types:
When conditional types act on a generic type, they become distributive when given a union type. For example, take the following:
type ToArray<Type> = Type extends any ? Type[] : never;
If we plug a union type into ToArray, then the conditional type will be applied to each member of that union.
type ToArray<Type> = Type extends any ? Type[] : never;
type StrArrOrNumArr = ToArray<string | number>; // string[] | number[]
Hence, if you use extends
with generic type, whole conditional type applies to each element in the union.
If you use extends
with non generic type, like you use in your second example, conditional types applies to the whole type.
You can even turn off distributivity in your fisrst example. Just wrap your generics into a square brackets:
type ValueSet = string | 'lol' | 0 | {a: 1} | number[] | 643;
type CustomExclude<T, U> = [T] extends [U] ? T : never;
// never
type Result1 = CustomExclude<ValueSet, number>;
Generic wrapped into square brackets is treated as a non generic type, just like in your first example.
In practice, this pattern is very useful. It is common to use T extends any
just to turn on distributivity.
Assume you have some object type. You want to get all keys and apply some modificator to them. In other words map them. COnsider this example:
type Foo = {
name: string;
age: number
}
// non verbose approach, distributivity
type ChangeKey<T> = keyof T extends string ? `${keyof T}-updated` : never
type Result = ChangeKey<Foo>
// middle verbose approach
type ChangeKey1<T> = {
[Prop in keyof T]: Prop extends string ? `${Prop}-updated` : never
}[keyof T]
type Result1 = ChangeKey1<Foo>
// verbose approach
type ChangeKey2<T extends Record<string, unknown>> = keyof {
[Prop in keyof T as Prop extends string ? `${Prop}-updated` : never]: never
}
type Result2 = ChangeKey2<Foo>
As you might have noticed, ChangeKey
is more elegant than others.