TypeScript official manual translation plan [3]: type contraction

Keywords: Front-end TypeScript

  • Note: at present, there is no Chinese translation of the latest official documents of TypeScript on the Internet, so there is such a translation plan. Because I am also a beginner of TypeScript, I can't guarantee the 100% accuracy of translation. If there are errors, please point them out in the comment area;
  • Translation content: the tentative translation content is TypeScript Handbook , other parts of the translated documents will be supplemented when available;
  • Project address: TypeScript-Doc-Zh , if it helps you, you can click a star~

Official document address of this chapter: Narrowing

Type contraction

Suppose there is a function called padLeft:

function padLeft(padding: number | string, input: string): string {
    trjow new Error('Not implemented yet!')
}

If padding is of type number, it will be used as the number of input prefixes. If it is of type string, it will be directly used as the prefix of input. Now let's try to implement the relevant logic. Suppose we want to pass in the padding parameter of type number to padLeft.

function padLeft(padding: number | string, input: string) {
  return " ".repeat(padding) + input;
// Argument of type 'string | number' is not assignable to parameter of type 'number'.
//  Type 'string' is not assignable to type 'number'.
}

Ah, an error is reported when passing in the padding parameter. TypeScript warns us that adding number to number | string may get unexpected results, which is indeed the case. In other words, we didn't explicitly check whether padding is a number at the beginning, and we didn't deal with the case that it is a string. So let's improve the code.

function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return " ".repeat(padding) + input;
  }
  return padding + input;
}

If you think it looks like boring JavaScript code, you're on the point. In addition to the type annotations we added, these TypeScript code really looks like JavaScript. The point here is that TypeScript's type system is designed to make it as easy for developers to write regular JavaScript code as possible without having to worry about type safety.

Although it may not seem much, in fact, there are many secrets in this process. Just like how TypeScript uses static typing to analyze runtime values, it covers type analysis on JavaScript runtime control flow structures such as if/else. At the same time, it also includes ternary expressions, loops, truth checks, etc., which can have an impact on types.

In the if conditional check statement, TypeScript found typeof padding === "number" and regarded it as a special code structure called "type protection". TypeScript follows the execution path that our program may reach, and analyzes the most specific type that a value may get at a given location. It looks at these special check statements (i.e. "type protection") and assignment statements, and refines the declared types into more specific types, which is called "type contraction". In many editors, we can observe these types of changes.

function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return " ".repeat(padding) + input;
                               ^^^^^                      
                   // (parameter) padding: number
  }
  return padding + input;
         ^^^^^^^  
        // (parameter) padding: string
}

TypeScript can understand several different constructs for contraction types.

typeof type protection

As we can see, the typeof operator supported by JavaScript can give basic information about the type of runtime value. Similarly, TypeScript expects this operator to return a string determined as follows:

  • "string"
  • "number"
  • "bigint"
  • "boolean"
  • "symbol"
  • "undefined"
  • "object"
  • "function"

As we can see in padLeft, this operator often appears in a large number of JavaScript libraries, and TypeScript can understand this operator to shrink types in different branches.

In TypeScript, checking the return value of typeof is a way of type protection. Because TypeScript can encode how typeof operates on different values, it also knows some strange expressions of this operator in JavaScript. For example, notice from the list above that typeof does not return the string "null". Let's look at the following example:

function printAll(strs: string | string[] | null) {
  if (typeof strs === "object") {
    for (const s of strs) {
                    ^^^^    
        // Object is possibly 'null'.
      console.log(s);
    }
  } else if (typeof strs === "string") {
    console.log(strs);
  } else {
    // do nothing
  }
}

In the printAll function, we try to check whether strs is an object to determine whether it is an array type (in JavaScript, an array is also an object type). But in JavaScript, typeof null actually returns "object"! This is one of the bug s left over from history.

Experienced developers may not be surprised, but not everyone has encountered this problem in JavaScript. Fortunately, TypeScript lets us know that strs only shrinks to string[] | null type, not string [].

This may be a good introduction to the "truth value" check.

Truth contraction

Truthiness may not be found in the dictionary, but you must have heard of it in JavaScript.

In JavaScript, we can use arbitrary expressions in conditional statements, such as & &, |, if statements, boolean negation (!), etc. For example, the if statement does not require that its condition must be boolean.

function getUsersOnlineMessage(numUsersOnline: number) {
  if (numUsersOnline) {
    return `There are ${numUsersOnline} online now!`;
  }
  return "Nobody's here. :(";
}

In JavaScript, structures like if will first "force" the condition to a boolean value to ensure that the accepted parameters are reasonable, and then select the corresponding branch based on whether the result is true or false.

Values like the following will become false after conversion:

  • 0
  • NaN
  • "" (empty string)
  • 0n (bigint version of 0)
  • null
  • undefined

Other values will become true after conversion. You can always convert a value to a boolean type by calling the Boolean function, or use a shorter!!. (the advantage of the latter is that TypeScript can infer it as a more specific literal boolean type true, while the former can only be inferred as Boolean)

// The following results are true
Boolean("hello"); // type: boolean, value: true
!!"world"; // type: true,    value: true

This feature is often used in coding, especially to prevent values such as null or undefined. For example, we try to use in the printAll function:

function printAll(strs: string | string[] | null) {
  if (strs && typeof strs === "object") {
    for (const s of strs) {
      console.log(s);
    }
  } else if (typeof strs === "string") {
    console.log(strs);
  }
}

We can see that by checking whether strs is the true value, we have successfully got rid of the previous error reports. This can at least prevent frightening errors such as the following:

TypeError: null is not iterable

Remember, however, that truth checking for primitive types is often error prone. For example, we try to rewrite printAll as follows:

function printAll(strs: string | string[] | null) {
  // !!!!!!!!!!!!!!!!
  //  Don't write that!
  // !!!!!!!!!!!!!!!!
  if (strs) {
    if (typeof strs === "object") {
      for (const s of strs) {
        console.log(s);
      }
    } else if (typeof strs === "string") {
      console.log(strs);
    }
  }
}

We wrap the whole function body in a truth check, but there is a potential problem: we may no longer be able to handle empty strings correctly.

TypeScript does not give an error prompt here, but if you are not familiar with JavaScript, it is a matter of concern. TypeScript can always help you catch bug s in advance, but if you choose not to deal with a value, there is only so much TypeScript can do without excessive constraints. If you need to, you can use a linter to ensure that you handle situations like this correctly.

On the truth contraction, the last point to note is that Boolean values are reversed! Negative branches can be filtered out:

function multiplyAll(
  values: number[] | undefined,
  factor: number
): number[] | undefined {
  if (!values) {
    return values;
  } else {
    return values.map((x) => x * factor);
  }
}

Equal contraction

TypeScript also uses switch statements and such as = = =,! = === And= Such equality checks are used to shrink the type. for instance:

function example(x: string | number, y: string | boolean) {
  if (x === y) {
    // We can now call any 'string' method on 'x' or 'y'.
    x.toUpperCase();
      ^^^^^^^^^^^^^    
    //(method) String.toUpperCase(): string
    y.toLowerCase();
      ^^^^^^^^^^^^^^      
    //(method) String.toLowerCase(): string
  } else {
    console.log(x);
               ^^               
               //(parameter) x: string | number
    console.log(y);
                ^^
               //(parameter) y: string | boolean
  }
}

In the above example, when we check that x and y are equal, TypeScript knows that their types must also be equal. Since string is a type shared by x and y, TypeScript knows that x and y must be string types in the first logical branch.

Similarly, we can also check specific literal values (relative to variables). In the previous example of truth contraction, the printAll function we wrote has potential errors because it does not properly handle empty strings. Alternatively, we can exclude null through a specific check, so that TypeScript can still correctly remove null from the type of strs.

function printAll(strs: string | string[] | null) {
  if (strs !== null) {
    if (typeof strs === "object") {
      for (const s of strs) {
                      ^^^^
                     // (parameter) strs: string[]
        console.log(s);
      }
    } else if (typeof strs === "string") {
      console.log(strs);
                  ^^^^ 
                 // (parameter) strs: string
    }
  }
}

Loose equality check for JavaScript = = and= You can also correctly the type of shrinkage. You may not be familiar with it. When checking whether a value = = null, you are not only checking whether the value is exactly null, but also checking whether the value is potentially undefined. The same is true for = = undefined: it checks whether the value is equal to null or undefined.

interface Container {
  value: number | null | undefined;
}
 
function multiplyValue(container: Container, factor: number) {
  // This check can remove both null and undefined
  if (container.value != null) {
    console.log(container.value);
                          ^^^^^^   
                        // (property) Container.value: number
 
    // Calculations can now be performed safely
    container.value *= factor;
  }
}

in operator contraction

JavaScript's in operator can determine whether an object has a property. TypeScript sees it as a way to shrink potential types.

For example, suppose there is a code "value" in x, "value" is a string literal, and X is a union type. Then a branch with a result of true shrinks x to a type with an optional attribute or a required attribute value, while a branch with a result of false shrinks x to a type with an optional attribute or a missing attribute value.

type Fish = { swim: () => void };
type Bird = { fly: () => void };
 
function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    return animal.swim();
  }
 
  return animal.fly();
}

Again, optional attributes appear in both branches when shrinking. For example, humans can both swim and fly (I mean by means of transportation), so in the in check, this type will appear in two branches at the same time:

type Fish = { swim: () => void };
type Bird = { fly: () => void };
type Human = { swim?: () => void; fly?: () => void };
 
function move(animal: Fish | Bird | Human) {
  if ("swim" in animal) {
    animal;
    ^^^^^^  
   // (parameter) animal: Fish | Human
  } else {
    animal;
    ^^^^^^   
   // (parameter) animal: Bird | Human
  }
}

instanceof contraction

JavaScript has an operator that checks whether a value is an instance of another value. More specifically, in JavaScript, x instanceof Foo can check whether the prototype chain of x contains Foo.prototype. Although we won't discuss it in depth here, and we will cover more about it when we explain the class later, it is still very useful for most values that can be constructed by new. As you may have guessed, instanceof is also a way of type protection. TypeScript can shrink types in branches protected by instanceof.

function logValue(x: Date | string) {
  if (x instanceof Date) {
    console.log(x.toUTCString());
                ^
              // (parameter) x: Date
  } else {
    console.log(x.toUpperCase());
                ^
              // (parameter) x: string
  }
}

assignment

As we mentioned earlier, when we assign a value to any variable, TypeScript will look at the right part of the assignment statement and appropriately shrink the variable type on the left.

let x = Math.random() < 0.5 ? 10 : "hello world!";
     ^  
   // let x: string | number
x = 1;
 
console.log(x);
            ^     
          // let x: number
x = "goodbye!";
 
console.log(x);
            ^ 
          // let x: string

Note that these assignment statements are valid. Although the observable type of x becomes number after the first assignment, we can still assign it a value of string type. This is because the declared type of x, that is, the initial type of x, is string | number, and assignability is always checked based on the declared type.

If we assign x a value of boolean type, we will throw an error because there is no boolean type in the declared type.

let x = Math.random() < 0.5 ? 10 : "hello world!";
    ^
  // let x: string | number
x = 1;
 
console.log(x);
            ^              
          // let x: number
x = true;
^
// Type 'boolean' is not assignable to type 'string | number'.
 
console.log(x);
            ^        
           // let x: string | number

Control flow analysis

So far, we have explained how TypeScript shrinks types in specific branches through some basic examples. However, in addition to analyzing each variable and finding type protection in conditional statements such as if and while, TypeScript has done a lot of other work.

function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return " ".repeat(padding) + input;
  }
  return padding + input;
}

padLeft is returned in the first if block. TypeScript can analyze this code and find that the rest of the function body (return padding + input;) is unreachable When padding is number. Finally, for the rest of the function body, it can remove number from the type of padding (that is, shrink the type string | number to string).

This reachability based code analysis is called "control flow analysis". When encountering type protection and assignment statements, TypeScript will use this flow analysis to shrink the type. When analyzing a variable, the control flow can be continuously disassembled and re merged, and we can also observe that the variables have different types at each node.

function example() {
  let x: string | number | boolean;
 
  x = Math.random() < 0.5;
 
  console.log(x);
              ^
             // let x: boolean
 
  if (Math.random() < 0.5) {
    x = "hello";
    console.log(x);
                ^
               // let x: string
  } else {
    x = 100;
    console.log(x);
                ^ 
              // let x: number
  }
 
  return x;
         ^
        // let x: string | number
}

Using type predicates

So far, we have used existing JavaScript structures to deal with type contraction, but sometimes you may want to control the changes of types in the code more directly.

To implement a user-defined type protection, we only need to define a function that returns a type predicate:

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

In this case, pet is Fish is a type predicate. The form of type predicate is parameterName is type. parameterName must be the parameter name of the current function signature.

Whenever you pass a parameter to isFish and call it, TypeScript will shrink the variable type to the specific type when the type is compatible with the initial type.

// Both swim and fly can be called
let pet = getSmallPet();
 
if (isFish(pet)) {
  pet.swim();
} else {
  pet.fly();
}

Note that TypeScript not only knows that pet is Fish in the if branch, but also knows its corresponding type in the else branch, because if it is not Fish, it must be Bird.

You can also use isFish type protection to filter out an array containing only Fish type from an array of Fish | Bird type:

const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()];
const underWater1: Fish[] = zoo.filter(isFish);
// Or use
const underWater2: Fish[] = zoo.filter(isFish) as Fish[];
 
// In more complex examples, you may need to repeat type predicates
const underWater3: Fish[] = zoo.filter((pet): pet is Fish => {
  if (pet.name === "sharkey") return false;
  return isFish(pet);
});

In addition, classes can use this is Type Deshrinking type.

Recognizable joint type

Most of the examples we've seen so far have shrunk a single variable to simple types, such as string, boolean, and number. Although this is common, in JavaScript, we often need to deal with slightly more complex structures.

Suppose we now need to encode the shapes of a circle and a square. A circle needs a radius and a square needs a side length. We will use the kind field to indicate the Shape currently being processed. Here is the first way to define a Shape:

interface Shape {
    kind: "circle" | "square";
    radius?: number;
    sideLength?: number;
}

Note that we use the combination of string literal types here: "circle" and "square". It can tell us whether the shape being processed is a circle or a square. We can avoid spelling mistakes by using "circle" | "square" instead of string.

function handleShape(shape: Shape) {
  // oops!
  if (shape.kind === "rect") {
// This condition always returns false because there is no overlap between type "circle" | "square" and type "rect".
    // ...
  }
}

We can write a getArea function, which can use the corresponding logic based on the type of shape currently processed. First, let's deal with the circle:

function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;
// The object may be 'undefined'
}

On enable strictNullChecks In this case, an error will be thrown - this is reasonable, after all, radius may not be defined. But what if we make a reasonable check on the kind attribute?

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
   // Object might be 'undefined'
  }
}

emm, TypeScript still has no way to start. We happen to encounter a scenario here, that is, we have more information about this value than the type checker. Therefore, here you can use a non null assertion (suffix shape.radius!) to indicate that radius must exist.

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius! ** 2;
  }
}

But this approach does not seem very ideal. We have to add a lot of non null assertions (!) to the type checker to make it sure that shape.radius has been defined, but if the code is removed, these assertions are easy to cause errors. Also, when disabled strictNullChecks In this case, we may accidentally access other fields (after all, when reading optional properties, TypeScript assumes that they exist). In short, there should be a better way to deal with it.

The problem with the encoding method of Shape is that the type checker is completely unable to judge whether radius and sideLength exist based on the kind attribute. We must communicate what we know to the type checker. With this in mind, let's define Shape again.

interface Circle {
  kind: "circle";
  radius: number;
}
 
interface Square {
  kind: "square";
  sideLength: number;
}
 
type Shape = Circle | Square;

Here, we appropriately divide Shape into two types. They have different kind attribute values, but radius and sideLength become necessary attributes in the corresponding types.

Let's see what happens when we try to access the radius of Shape:

function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;
                         ^^^^^^
// Property 'radius' does not exist on type 'Shape'.
  // Property 'radius' does not exist on type 'Square'.
}

Just like when defining Shape for the first time before, an error is still thrown. Previously, when radius is an optional attribute, we saw an error (only when enabled) strictNullChecks Because TypeScript has no way to know whether this property really exists. Now shape is a union type. TypeScript tells us that shape may be Square, and Square does not define radius attribute! Both explanations are reasonable, but only the latter will be disabled strictNullChecks An error is still thrown.

So what if we check the kind attribute again at this time?

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
                     ^^^^^  
                   // (parameter) shape: Circle
  }
}

No more code errors! When each type in a union type contains a public attribute of a literal type, TypeScript will treat it as a recognized union type and confirm that the type is a member of the union type by shrinking.

In this case, kind is the public attribute (that is, a recognizable attribute of shape). By checking whether the kind attribute is "Circle", we can exclude all types in shape whose kind attribute value is not "Circle". That is, you can shrink the shape type to the Circle type.

Similarly, this check can also be used in switch statements. Now we can write a complete getArea function, and it doesn't have any trouble! Non null value assertion symbol.

function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
                       ^^^^^ 
                      // (parameter) shape: Circle
    case "square":
      return shape.sideLength ** 2;
             ^^^^^ 
            // (parameter) shape: Square
  }
}

The focus of this example is the encoding of Shape. It is very important to convey important information to TypeScript. We have to tell it that Circle and Square are two different types with their own kind attribute values. In this way, we can write type safe TypeScript code, which looks no different from the JavaScript we write. Knowing this, the type system can also do "correct" processing to clarify the specific type in each branch of the switch.

By the way, you can try to write the above example and delete some return keywords. You will see that type checking can effectively avoid bug s when different clauses are accidentally encountered in switch statements

Recognizable union types are very useful, not just in circles and squares in this example. They are also suitable for representing any type of messaging scheme in JavaScript, such as sending messages on the network (client / server communication) or encoding mutation in the state management framework.

never type

When contracting types, you can reduce the union type to one of the only remaining types. At this time, you have basically ruled out all possibilities, and there are no remaining types to choose from. At this point, TypeScript will use the never type to represent a state that should not exist.

Exhaustive inspection

Never type can be assigned to any type, but no type can be assigned to never except never itself. This means that you can use type contraction and never to perform exhaustive checks in a swith statement block.

For example, in the default branch of getArea function, we can assign shape to the value of never type. In this way, when any possible situation is not handled in the previous branch, an error is bound to be thrown in this branch.

type Shape = Circle | Square;
 
function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    default:
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}

Adding a new member to the Shape union type will cause TypeScript to throw an error:

interface Triangle {
  kind: "triangle";
  sideLength: number;
}
 
type Shape = Circle | Square | Triangle;
 
function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    default:
      const _exhaustiveCheck: never = shape;
           ^^^^^^^^^^^^^^^^    
          //Type 'Triangle' is not assignable to type 'never'.
      return _exhaustiveCheck;
  }
}

Posted by Misery_Kitty on Sat, 20 Nov 2021 02:01:56 -0800