3
头图

Preface

The official documentation of TypeScript has long been updated, but the Chinese documents I can find are still in the older version. Therefore, some chapters that have been newly added and revised have been personally translated and sorted out.

This article is compiled from https://www.typescriptlang.org/docs/handbook/2/narrowing.html

This article does not completely follow the translation of the original text. It also explains and supplements some of the content.

Narrowing

Imagine we have such a function, the function is called padLeft:

function padLeft(padding: number | string, input: string): string {
  throw new Error("Not implemented yet!");
}

The functions implemented by this function are:

If the parameter padding is a number, we add the same number of spaces input padding is a string, we directly add it in front of input

Let's implement this logic:

function padLeft(padding: number | string, input: string) {
  return new Array(padding + 1).join(" ") + input;
    // Operator '+' cannot be applied to types 'string | number' and 'number'.
}

If you write in this way, padding + 1 the editor will be marked in red, indicating an error.

This is TypeScript warning us that if we add a number type (that is, the number 1 in the example) and a number | string type, it may not achieve the result we want. In other words, we should first check padding is a number , or deal with padding is string , then we can do this:

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

This code may not seem interesting, but in fact, TypeScript does a lot of things behind it.

TypeScript must learn to analyze the specific types of statically typed values at runtime. At present, TypeScript has implemented if/else , ternary operators, loops, and truth checking.

In our if statement, TypeScript will think that typeof padding === number is a special form of code, we call it type guard (type guard) , TypeScript will follow the possible path during execution, and the analysis value will be in a given position The most specific type.

TypeScript's type checker will take these type protection and assignment statements into consideration, and this the type to a more precise type is called narrowing . In the editor, we can observe the type change:

image.png

From the figure above, we can see that in the if statement and the remaining return statements, padding is deduced to a more precise type.

Next, we will introduce the various contents involved in narrowing

typeof type guards

JavaScript itself provides the typeof operator, which can return the basic type information of a value at runtime, and will return the following specific strings:

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

typeof operator is widely used in many JavaScript libraries, and TypeScript can already understand and narrow the types in different branches.

In TypeScript, checking typeof is a type of protection. TypeScript knows the typeof different values of 061d45ce92cbb4, and it can also recognize some weird places in JavaScript. For example, in the above list, typeof does not return the string null . 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 this printAll function, we try to determine whether strs is an object. The original purpose is to determine whether it is an array type, but in JavaScript, typeof null will also return object . And this is an unfortunate historical accident of JavaScript.

Skilled users will naturally not be surprised, but not everyone is so skilled. Fortunately, TypeScript will let us know that strs is narrowed to strings[] | null , not just string[] .

Truthiness narrowing

In JavaScript, we can use any expression in conditional statements, such as && , || , ! etc. For example, a if does not require a condition and the result is always of type boolean

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

This is because JavaScript will do implicit type conversions, such as 0 , NaN , "" , 0n , null undefined these values will be converted to false true will be converted to 061d45ce92cc7d.

Of course, you can also use the Boolean function to force the boolean , or use the shorter !! :

// both of these result in 'true'
Boolean("hello"); // type: boolean, value: true
!!"world"; // type: true,    value: true

This method of use is very popular, especially suitable for preventing values like null and undefiend For example, we can use this printAll

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);
  }
}

It can be seen that in this way, the error was successfully removed.

But it should be noted that truth checking on basic types can easily lead to errors. For example, if we write the printAll function like this:

function printAll(strs: string | string[] | null) {
  // !!!!!!!!!!!!!!!!
  //  DON'T DO THIS!
  //   KEEP READING
  // !!!!!!!!!!!!!!!!
  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 contents of the original function body in a if (strs) truth value check. There is a problem here, that is, we cannot handle the empty string correctly. If the passed in is an empty string, the truth check judges to be false , and it will enter the wrong processing branch.

If you are not familiar with JavaScript, you should be aware of this situation.

Another way to narrow down the type through truth checking is through the ! operator.

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

Equality narrowing

Typescript will also use switch statements and equivalence checks such as == !== == != to narrow down the type. for example:

image.png

In this example, we judged whether x and y are completely equal. If they are completely equal, then their types must be completely equal. The string type is the only possible same type of x and y So in the first branch, x and y must be of type string

Judging the specific literal value also allows TypeScript to correctly determine the type. In the previous section of truth-value narrowing, we wrote a printAll that did not handle the empty string correctly. Now we can use a more specific judgment to rule out the null :

image.png

JavaScript loose equality operators such as == and != can also be narrowed correctly. In JavaScript, the == null method does not accurately determine that this value is null , it may also be undefined . The == undefined , but with this point, we can easily determine whether a value is neither null nor undefined :

image.png

in operator narrows

There is a in operator in JavaScript that can determine whether an object has a corresponding attribute name. TypeScript can also use this to narrow down types.

For example, in "value" in x , "value" is a string literal, and x is a union type:

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

Through "swim" in animal , we can accurately narrow down the type.

And if there are optional attributes, such as a human being either swim or fly (with equipment), it can also be displayed correctly:

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 narrows

instanceof is also a type protection, TypeScript can also narrow down by identifying the correct type of instanceof

image.png

Assignments

TypeScript can correctly narrow the left value according to the right value of the assignment statement.

image.png

Note that these assignments are valid, even if we have x changed number type, but we can still change it to string type, because x initially declared string | number , when the assignment will be based on a formal declaration Check.

So if we assign x to a boolean type, an error will be reported:

Control flow analysis

So far we have talked about some basic examples of narrowing types in TypeScript. Now we look at the type protection in conditional control statements such as if while

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

In the first if statement, because there is a return statement, TypeScript can determine the remaining part return padding + input through code analysis. If the padding is of number , it cannot be reached ( unreachable is in the remaining part) , The number type will be deleted from the number | string type.

This code analysis based on reachability ( reachability ) is called control flow analysis. When encountering type protection and assignment statements, TypeScript uses this method to narrow down the type. In this way, a variable can be observed to become a different type:

image.png

Type predicates

In some documents, type predicates will be translated into type predicate . Considering that predicate as a verb also has the meaning of declaration, declaration, and assertion, which is distinguished from Type Assertion. Here I simply translate it into a type predicate.

If you quote this explanation:

In mathematics, a predicate is commonly understood to be a Boolean-valued function_ P_: _X_→ {true, false}, called the predicate on _X_.

The so-called predicate is a function that returns the value of boolean

Then we continue to look down.

If you want to control the type change directly through the code, you can customize a type protection. The implementation method is to define a function, and the type returned by this function is a type judgment. An example is as follows:

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

In this case, pet is Fish is our type of predicate, a predicate type using parameterName is Type form, but parameterName must be current parameter name of the function.

When isFish is called by passing in a variable, TypeScript can narrow this variable to a more specific type:

// Both calls to 'swim' and 'fly' are now okay.
let pet = getSmallPet();
 
if (isFish(pet)) {
  pet.swim(); // let pet: Fish
} else {
  pet.fly(); // let pet: Bird
}

Note that, TypeScript does not just know if statement in the pet is Fish type, also know else branch where, pet is Bird type, after all pet on two possible types.

You can also use isFish in Fish | Bird to filter to obtain an array of only type Fish

const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()];
const underWater1: Fish[] = zoo.filter(isFish);
// or, equivalently
const underWater2: Fish[] = zoo.filter(isFish) as Fish[];
 
// 在更复杂的例子中,判断式可能需要重复写
const underWater3: Fish[] = zoo.filter((pet): pet is Fish => {
  if (pet.name === "sharkey") return false;
  return isFish(pet);
});

Discriminated unions (Discriminated unions)

Let us imagine such a process Shape (such Circles , Squares function), Circles records its radius properties Squares records its side lengths properties, we use a kind field to distinguish the determination process is Circles or Squares , which Is the initial definition Shape

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

Note that here we use a union type, "circle" | "square" , instead of a string , we can avoid some spelling errors:

function handleShape(shape: Shape) {
  // oops!
  if (shape.kind === "rect") {
    // This condition will always return 'false' since the types '"circle" | "square"' and '"rect"' have no overlap.
    // ...
  }
}

Now we write a getArea function to get the area, and the way to calculate the area of a circle and a square is different, let's deal with the case Circle

function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2; // 圆的面积公式 S=πr²
  // Object is possibly 'undefined'.
}

In strictNullChecks mode, TypeScript will complain, after all radius value may indeed be undefined , that if we According kind judgment about it?

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

You will find that TypeScript is still kind errors, even if we judge that 061d45ce92d2ab is circle , but because radius is an optional attribute, TypeScript still thinks that radius may be undefined .

We can try to assert a non-empty (non-null assertion), that shape.radius plus a ! to represent radius must exist.

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

But this is not a good way. We have to use a non-empty assertion to convince the type checker that shape.raidus exists at this time. We set it as an optional attribute when defining the radius, but here we consider it to be certain. Existence, the semantics before and after are also inconsistent. So let us think about how we can better define it.

Shape at this time is that the type checker has no way to determine whether the radius and sideLength kind attribute. This is what we need to tell the type checker, so we can define Shape like this:

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

Here, we Shape according kind into two different types of properties, radius and sideLength are as defined in the respective types required .

Let us see what happens if we get radius directly?

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'.
}

As we defined Shape first time, there are still errors.

When defining radius as optional at the beginning, we will get an error (in strickNullChecks mode), because TypeScript cannot determine that this attribute must exist.

And now the error is reported because Shape is a joint type, TypeScript can recognize that shape may also be a Square , and Square does not have radius , so an error will be reported.

But at this time, should we kind attribute?

image.png

You will find that the error is removed in this way.

When the union type in each type, contains a common literal types of properties, TypeScript will think this is a discernible joint (discriminated of Union) , then a member of a specific type can be narrowed.

In this example, kind is the public attribute (discriminant as Shape).

This also applies to the switch statement:
image.png

The key here is how to define Shape and tell TypeScript that Circle and Square are two types that are completely separated based on the kind In this way, the type system can deduce the correct type in each branch of the switch

Distinguishable joint applications are far more than these, such as message mode, such as client-server interaction, and for example in the state management framework, are very practical.

Imagine that in the message mode, we will monitor and send different events. These are distinguished by names, and different events will carry different data. This is applied to distinguishable unions. The interaction and state management between the client and the server are similar.

never type

When narrowing down, if you exhaust all possible types, TypeScript will use a never type to represent an impossible state.

Let us continue to look down.

Exhaustiveness checking


The never type can be assigned to any type, however, no type can be assigned to never (except never itself). This means that you can never switch statement to do an exhaustive check.

For example, add a default getArea function, assign shape to the never type, when there is a branch that has not been processed, never will come into play.

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;
  }
}

When we Shape type, but do not do the corresponding processing, it will cause a TypeScript 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;
  }
}

Because of the narrowing characteristics of TypeScript, when the execution reaches default , the type is narrowed to Triangle , but because any type cannot be assigned to the never type, this will cause a compilation error. In this way, you can ensure that the getArea function always shape all the possibilities of 061d45ce92d58f.

TypeScript series

The TypeScript series of articles consists of three parts: official document translation, important and difficult analysis, and practical skills. It covers entry, advanced, and actual combat. It aims to provide you with a systematic learning TS tutorial. The entire series is expected to be about 40 articles. Click here to browse the full series of articles, and suggest to bookmark the site by the way.

WeChat: "mqyqingfeng", add me to the only reader group in Kongyu.

If there are mistakes or not rigorous, please correct me, thank you very much. If you like or have some inspiration, star is welcome, which is also an encouragement to the author.


冴羽
9.3k 声望6.3k 粉丝

17 年开始写前端文章,至今 6 个系列,上百篇文章,全网千万阅读