3

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?

2 Answers 2

3

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 first 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.

Sign up to request clarification or add additional context in comments.

1 Comment

Thank you so much, the "distributive" nature of Generics was the missing piece of the puzzle to correctly understand this behaviour.
1

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

2 Comments

"The version with generics is going to step through all the individual types in the union [...] and evaluate them individually. Then you get a union of whichever individual types survived." But why? I mean what is the magic behind the scene that let this happen? Why the same expression changes the operation it performs if declared as a new type via Generics?
They designed generics to work that way, since it's a useful utility for the language to have. I think @captain-yosasarian's post has some information that helps you

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.