This article is a further explanation of the types of distributed conditions mentioned in the Geek Time and Early Chat livestream, but you don’t need to have watched the livestream, the pre-knowledge section will be included.

Note: The focus of this article is on distributed condition types, and the section on pre-condition types won’t go into great depth, but it will suffice.

Again, the main content of this article comes primarily from the other side of TypeScript: Type Programming (2021 reprint), which covers distributed conditional typing, is also recommended for students who are still studying TypeScript. I’m sure you’ll be better than 79.8% of TypeScript users by the end of this article.

Conditions in the

In this article, we will only introduce the extends statement in common situations. Other knowledge related to condition types such as principles, infer keyword, and generic constraints based on condition types will not be introduced (because it has nothing to do with the main idea of this article).

Common extends statements can be broken down into the following situations:

  • Literal types and their primitive types
  • Subtype relationships between base classes and derived classes
  • Subtype relationships based on structured type systems
  • The subtype relationship between a union type and its branches
  • Top Type and Bottom Type
  • Distributed condition type

We’ll go through them all.

Literal types and their primitive types

Literal types include numeric literals, string literals, and Boolean string types, which are more meaningful type information than primitive types. For example, we can annotate a return value determined by an optional value:

Template string types are generally considered to be close to literal types.

type ResCode = 200 | 400 | 500;
Copy the code

We can annotate a definite set of strings:

interface Foo {
  status: 'success' | 'failure'
}
Copy the code

Can also be mixed:

type Mixed = true | 599 | 'Linbudu'
Copy the code

In essence, a literal type can be understood as a further convergence of the original type, a more precise type than the original type, and therefore must be true for the extends statement.

// true
type _T1 = 'linbudu' extends string ? true : false;
Copy the code

On the other hand, we can think of literal types as inheriting from primitive types, as in the following pseudocode:

class LinbuduLiteralType extends String {
  public value = 'Linbudu';
}
Copy the code

Subtype relation

The subtype relationship between a base class and a derived class is not explained.

class Base { name! :string;
}

class Derived extends Base { age! :number;
}

// true
type _T1 = Derived extends Base ? true : false;
Copy the code

A similar situation exists, namely, the subtype relationships determined by the structured type system:

We’ll talk about the difference between structured and nominal type systems later.

type _T2 = { name: 'linbudu'; } extends Base
  ? true
  : false;

type _T3 = { name: 'linbudu'; age: 18; job: 'engineer' } extends Base
  ? true
  : false;
Copy the code

The object we define manually here doesn’t really have an extends Base Class, but since its internal properties are the same as the Base type (with additional extensions from _T3), the structured type system determines type compatibility by comparing internal properties with property types. So extends is also true here.

A more special case is the comparison of an empty object {} :

type _T4 = {} extends{}?true
  : false;

type _T5 = { name: 'linbudu';  } extends{}?true
  : false;
  
type _T6 = string extends{}?true
  : false;
Copy the code

Essentially similar to base and derived classes, an empty object can be considered a subset of any object (even a primitive type) because it has no internal attributes.

Union types and their branches

The comparison of joint types is to compare whether all branches of the former exist in the latter, or whether the former is a subset of the latter:

// true
type _T7 = 'a' extends 'a' | 'b' | 'c' ? true : false;

// true
type _T8 = 'a' | 'b' extends 'a' | 'b' | 'c' ? true : false;

// false
type _T9 = 'a' | 'b' | 'wuhu! ' extends 'a' | 'b' | 'c' ? true : false;
Copy the code

We’ll learn more about this in the distributed condition Types section.

Top Type and Bottom Type

Yes, this is a separate point of knowledge, we will… So you can see how important knowledge of the TypeScript type system is.

In TypeScript we say that any and unknown are Top types and never are Bottom types. Top Type means that they are at the Top of the Type hierarchy. That is, any Type is a subtype. OtherType extends Any and OtherType extends Unknown must be true. The Bottom Type is at the Bottom of the Type hierarchy, meaning that a Type cannot be subdivided, and no other Type can be assigned to it except never itself. This means that it is a subtype of any type, meaning that never extends OtherType must be true.

This extends chain provides a visual look at TypeScript’s type hierarchy:

// 8, that is, all extends is true
type _Chain = never extends 'linbudu'
  ? 'linbudu' extends 'linbudu' | 'budulin'
    ? 'linbudu' extends string
      ? string extends{}? {}extends Object
          ? Object extends any
            ? Object extends unknown
              ? any extends unknown
                ? unknown extends any
                  ? 8
                  : 7
                : 6
              : 5
            : 4
          : 3
        : 2
      : 1
    : 0
  : never;
Copy the code

Distributed condition type

Distributed Conditional Types are one of the special features of Conditional Types in TypeScript, so they are also referred to as the distributed nature of Conditional Types.

In fact, there is no special obscure round and round about it, is to meet certain conditions will inevitably happen, just like hungry to eat, sleepy to sleep. So there’s no need or reason to be in awe of it, and watch me undress it in front of you.

Consider an example of the use of the built-in utility type Extract:

type Extract<T, U> = T extends U ? T : never;
Copy the code
interface IObject {
  a: string;
  b: number;
  c: boolean;
}

// 'a'|'b'
type _ExtractedKeys1 = Extract<keyof IObject, 'a'|'b'>;

// 'a'|'b'
type _ExtractedKeys2 = Extract<'a'|'b'|'c'.'a'|'b'>;

// never
type _ExtractedKeys3 = 'a'|'b'|'c' extends 'a'|'b' ? 'a'|'b'|'c' : never;
Copy the code

Essentially, the code executed by these three type aliases is the same, but why does 3 behave differently from 1 and 2 (although we understand this extends doesn’t hold)?

If you look at the difference between the two cases, you will see that 1 and 2 are evaluated by the generic parameters passed in, and 3 is evaluated directly using the union type.

Remember the first difference: as a generic parameter.

Here’s another example:

type Naked<T> = T extends boolean ? "Y" : "N";
type Wrapped<T> = [T] extends [boolean]?"Y" : "N";

// "N" | "Y"
type Result1 = Naked<number | boolean>;

// "N"
type Result2 = Wrapped<number | boolean>;
Copy the code

This is all done by generic parameters, but the result is different. Why is the first one to get the union type? [1, true] is incompatible with [true, true].

For the first one, you may have noticed that the union type of the result corresponds to the use of number and Boolean respectively, as if Yu qian’s father was also named Yu. So why isn’t the second one broken down like this?

Remember the second difference: whether the generic parameter is wrapped in an array in the condition type.

In fact, there are two differences that are necessary for distributed condition types to occur:

  • First, you have to be a syndication type
  • Second, your union type must be passed in as a generic parameter
  • Finally, your generic parameters need to be bare type parameters in conditional type statements, that is, not wrapped in []

Together, we get the official explanation: for check types that are bare type parameters, the condition type is automatically distributed to the union type at instantiation time.

Conditional types in which the checked type is a naked type parameter are called distributive conditional types. Distributive conditional types are automatically distributed over union types during instantiation.

Now we can talk about what distribution represents here. In the first example, this is actually a judgment process:

// ('a' extends 'a'|'b') | ('b' extends 'a'|'b') | ('c' extends 'a'|'b')
// 'a'|'b'|never
// 'a'|'b'
type _ExtractedKeys1 = Extract<keyof IObject, 'a'|'b'>;
Copy the code

That is, the branches of the union type are separately taken out for the conditional type statement in turn. Similarly, in the second example, the Naked tool type will also distribute the incoming joint type parameters, while Wrapped does not meet the conditions of the Naked type parameters, so the distribution is not established, so it will not be distributed.

Why, you might wonder, was a distributed condition type designed specifically? In fact, it’s one of the cornerstones of the TypeScript type system, and a lot of tool types rely on it to do associate-type filtering, mapping, Pick/Omit, and other interface tailoring.

Application of distributed conditional types

TypeScript has several built-in utility types based on distributed conditional types:

type Extract<T, U> = T extends U ? T : never;

type Exclude<T, U> = T extends U ? never : T;

type NonNullable<T> = T extends null | undefined ? never : T;
Copy the code

Their functions are as follows:

  • The type branches in set T also exist in set U, that is, the intersection of T and U
  • Exclude, extracts the type branches in set T that do not exist in set U, that is, the difference set between T and U
  • NonNullable: Removes null and undefined from collections

The way they work is the distributed condition type, you see the difference set and the intersection, does your mathematical DNA move?

Of course, we can easily implement union and complement:

export type Concurrence<A, B> = A | B;

export type Complement<A, B extends A> = Exclude<A, B>;
Copy the code

Let me just draw a picture. I’m not one to draw pictures.

The only thing to notice is the complement, which is actually the difference set of the special case, where U is a subset of T, where you have the difference set of T with respect to U + U = T.

So here we’ve implemented the ordinary set case, and if you start frowning, you’ll see that it’s not easy. What if we’re dealing with an object? If we take a union of objects, it’s not that simple. For example, if a key exists in both objects, which object’s key value type prevails? For example, what if we want to merge objects, and just use the new object’s key-value type to override the original object’s same-key key-value type, but we don’t want to merge the new key?

This is what I call in type programming, the process of taking an interface, breaking it up into parts, doing something to it, and then merging it. This idea is reflected in many tool types, such as:

export type MarkPropsAsOptional<
  T extends Record<string.any>,
  K extends keyof T = keyof T
> = Omit<T, K> & Partial<Pick<T, K>>;
Copy the code

The purpose of this tool type is to make a specified part of the interface optional, unlike Partial. The idea is to split the interface into invariant + and mark it as optional, then apply Partial to the latter part and merge it.

Going back to object coverage, the idea is the same.

  • Merge all key-value pairs of T and U, with the key-value type of U having higher precedence
    • The part where T is more than U: The difference set of T with respect to U
    • The part of U that is more than T: the difference set of U with respect to T
    • The intersection of T and U, passExtract<U, T>T is regarded as a post-entry set to realize the main set of U, that is, the type of U has a higher priority.

Before we start, we also need to implement several assistive tool types, such as object intersection, object difference set, and assistive tool types for assisting their object key set intersection, object key difference set. For ease of understanding, we call the Extract Intersection and Exclude Difference.

type PlainObjectType = Record<string.any>;

export type Intersection<A, B> = A extends B ? A : never;

export type Difference<A, B> = A extends B ? never : A;

export type ObjectKeysIntersection<
  T extends PlainObjectType,
  U extends PlainObjectType
> = Intersection<keyof T, keyof U> & Intersection<keyof U, keyof T>;

export type ObjectKeysDifference<
  T extends PlainObjectType,
  U extends PlainObjectType
> = Difference<keyof T, keyof U>;

export type ObjectIntersection<
  T extends PlainObjectType,
  U extends PlainObjectType
> = Pick<T, ObjectKeysIntersection<T, U>>;

export type ObjectDifference<
  T extends PlainObjectType,
  U extends PlainObjectType
> = Pick<T, ObjectKeysDifference<T, U>>;
Copy the code

This allows the Merge to be higher priority with the new object type:

type Merge<
  T extends PlainObjectType,
  U extends PlainObjectType
  // The part of T that has more than U, plus the part of T that intersects with U (U has higher priority depending on the type, plus the part of U that has more than T)
> = ObjectDifference<T, U> & ObjectIntersection<U, T> & ObjectDifference<U, T>;
Copy the code

Similarly, to ensure that the original object type has a higher priority, reverse the lower intersection:

type Assign<
  T extends PlainObjectType,
  U extends PlainObjectType
  // The part where T is more than U, plus the part where T is the intersection with U (T has a higher priority depending on the type, plus the part where U is more than T)
> = ObjectDifference<T, U> & ObjectIntersection<T, U> & ObjectDifference<U, T>;
Copy the code

extension

& with |

Have noticed that some classmates in front of the our common set intersection using |, but at the intersection between objects using the cross type & :

export type Concurrence<A, B> = A | B;

// This IntersectionTypes is not this Intersection
// The constraint must be an object type.
export type IntersectionTypes<T, U, K> = T & U & K;
Copy the code

This is because only for object types does ampersand act as a merge, whereas for primitive types and for union types, ampersand really acts as an intersection.

// 'a'
type _T1 = ('a' | 'b') & ('a' | 'd' | 'e' | 'f')

// never, because string and number do not overlap
type _T1 = string & number;
Copy the code

homework

Xiao Ming wants to merge two objects A and B, but he doesn’t want more parts of object B than object A. He just wants to merge the same key value type of object B. Can you help him?