一文读懂 TypeScript 类型收窄

481 阅读7分钟

类型推论

TypeScript 里,在有些没有明确指出类型的地方,类型推论会帮助提供类型。

class Rhino {
  name: string;
  constructor() {
    this.name = "";
  }
}

class Elephant {
  age: number;
  constructor() {
    this.age = 0;
  }
}

class Snake {
  job: string;
  constructor() {
    this.job = "";
  }
}

let x = [0, 1, null]; // x: (number | null)[]
let y = Math.random() < 0.5 ? 100 : "Hello world"; // y: string | number
let z = [new Rhino(), new Elephant(), new Snake()]; // z: (Snake | Rhino | Elephant)[]

如果没有找到最佳通用类型的话,类型推断的结果为联合类型。

联合类型和类型守卫

// 联合类型
type Types = number | string

function typeFn (type: Types, input: string) {
  // 假如这样定义type,则需要判断
}

如果不判断会报错,情况如下

function typeFn (type: Types, input: string) {
  // 报错 运算符 + 号不能应用于 string
  return new Array(type + 1).join('') + input
}
function typeFn(type: Types, input: string) {
  // 类型守卫
  if (typeof type === "number") return new Array(type + 1).join(" ") + input;
  return type + input;
}

类型的窄化就是根据判断类型重新定义更具体的类型。

那么问题来了学这玩意干啥?JS不香吗?
个人观点:
使用 TypeScript 可以帮你降低 JavaScript 弱语言的脆弱性,帮你减少由于不正确类型导致错误产生的风险,以及各种 JavaScript 版本混杂造成错误的风险。
TypeScript 只是把高级语言的强类型这个最主要的特征引入 JavaScript,就解决了防止我们在编写 JavaScript 代码时因为数据类型的转换造成的意想不到的错误,增加了我们排查问题的困难性。

typeof的类型守卫

"string"
"number"
"bigint" // ES10新增
"boolean"
"symbol" // ES6新增
"undefined"
"object"
"function"

注意:typeof null 等于 object

function strOrName (str: string | string[] | null) {
  if (typeof str === 'object') {
    for (const s of str) {
      // 报错 因为str有可能是 null
      console.log('s', s)
    }
  } else if (typeof str === 'string') console.log('str', str)
  else console.log('')
}

真值窄化

0
NAN
""
0n // 0的 bigint 版本
null
undefined

避免 null 的错误可以利用真值窄化

function strOrName (str: string | string[] | null) {
  // 利用真值判断
  if (str && typeof str === 'object') {
    for (const s of str) {
      console.log('s', s)
    }
  } else if (typeof str === 'string') console.log('str', str)
  else console.log('')
}

或者这样也行

function valOrName(values: number[] | undefined, filter: number): number[] | undefined {
  if (!values) return values;
  return values.filter((item) => item > filter);
}

小结:真值窄化帮助我们更好的处理 null / undefined / 0 等值

相等性窄化

相等性窄化就是利用 ===、!==、==、!= 等运算符进行窄化

Example1:

function example(x: string | number, y: string | boolean) {
  if (x === y) {
    x.toUpperCase(); // x: string
    y.toLowerCase(); // y: string
  } else {
    console.log(x); // x: string | number
    console.log(y); // y: string | boolean
  }
}

Example2:

function strOrName(str: string | string[] | null) {
  if (str === null) return;

  if (typeof str === "object") {
    for (const s of str) {
      console.log(s); // s: string
    }
  } else if (typeof str === "string") console.log(str); // str: string
  else console.log("");
}

Example3:

interface Types {
  value: number | null | undefined;
}

function valOrType(type: Types, val: number) {
  // null 和 undefined 都是false,只能是number
  if (type.value != null) {
    type.value *= val;
  }
}

in 操作符窄化

in 是检查对象中是否有属性,现在充当一个“type guard”的角色

interface A { a: number;}
interface B { b: string;}

function foo(x: A | B) {
  if ("a" in x) return x.a;

  return x.b;
}

is 关键字窄化

TypeScript 中 is 关键字表示是否属于某个类型,可以有效地缩小类型范围

const isString = (val: any): val is string => {
  return typeof val === 'string'
}

isString 是判断传入参数是否为 String 类型的函数,用 is string 限定了返回值类型,这里估计有人会有这样的疑问:用 Boolean 类型也可以限制函数类型。Boolean 的确可以限制 isString 函数的返回类型,但是使用 is string 可以更好地缩小类型范围,避免一些隐藏的错误。

当用 Boolean 来限制 isString() 函数返回类型,以下代码不会有编译错误,运行时会报错误,因为 toExponential()Number 类型的一个方法,在 String 类型上不存在。

function example (val: any) {
  if (isString(val)) {
    console.log(val.length)
    console.log(val.toExponential(2))
  }
}
// 运行时报错 Uncaught TypeError: val.toExponential is not a function
example('test')

另外,如果将 val.toExponential(2) 放在 isString 外面,也会是编译时不会出现错误,运行时会报错误。

function example(val: any) {
  if (isString(val)) console.log(val.length);
  console.log(val.toExponential(2));
}
// 运行时报错 Uncaught TypeError: val.toExponential is not a function
example("test");

但如果用 is String 来限定 isString() 函数返回值,此时会直接报编译错误,当然运行时肯定也会抛出错误:

function example(val: any) {
  if (isString(val)) {
    console.log(val.length);
    // 类型“string”上不存在属性“toExponential”。
    console.log(val.toExponential(2));
  }
}

example("test"); // Uncaught TypeError: val.toExponential is not a function

根据上面的例子,就会理解 TypeScript 中 is 关键字可以缩小类型范围,可以帮助开发者在编辑阶段发生错误,从而避免一些隐藏的运行时错误。

项目实战中使用 filter 方法过滤未定义的值:

function isDefined<T>(x: T | undefined): x is T {
  return x !== undefined;
}

const supermans = ["a", "b", "c", "d"];
const members = ["a", "b"]
  .map((item) => supermans.find((n) => item === n))
  .filter(isDefined);

instanceof 操作符窄化

instanceof 表达式的右侧必须属于类型 any,或属于可分配给 Function 接口类型的类型。

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

never 的妙用

Unreachable code 检查

通常来说,我们手动标记函数返回值为never类型,来帮助编译器识别「unreachable code」,并帮助我们收窄(narrow)类型。下面是一个没标记的例子:

function throwError() {
  throw new Error();
}

function firstChar(msg: string | undefined) {
  if (msg === undefined) throwError();
  let chr = msg.charAt(1); // 报错:Object is possibly 'undefined'.
}

由于编译器不知道throwError是一个无返回的函数,所以throwError()之后的代码被认为在任意情况下都是可达的,让编译器误会msg的类型是string | undefined
这时候如果标记上了never类型,那么msg的类型将会在空检查之后收窄为string

function throwError(): never {
  throw new Error();
}

function firstChar(msg: string | undefined) {
  if (msg === undefined) throwError();
  let chr = msg.charAt(1);
}

类型运算

最小因子

Promise.race合并的多个Promise,有时是无法确切知道时序和返回结果的。现在我们使用一个Promise.race来将一个有网络请求返回值的Promise和另一个在给定时间之内就会被rejectPromise合并起来。

async function fetchNameWithTimeout(userId: string): Promise<string> {
  const data = await Promise.race([
    fetchData(userId),
    timeout(3000)
  ])
  return data.userName;
}

下面是一个timeout函数的实现,如果超过指定时间,将会抛出一个Error。由于它是无返回的,所以返回结果定义为了Promise<never>

function timeout(ms: number): Promise<never> {
  return new Promise((_, reject) => {
    setTimeout(() => reject(new Error("Timeout!")), ms)
  })
}

很好,接下来编译器会去推断Promise.race的返回值,因为race会取最先完成的那个Promise的结果,所以在上面这个例子里,它的函数签名类似这样:

function race<A, B>(inputs: [Promise<A>, Promise<B>]): Promise<A | B>

代入fetchDatatimeout进来,A则是 { userName: string },而B则是never。因此,函数输出的promise返回值类型为{ userName: string } | never。 又因为never是最小因子,可以消去。故返回值可简化为{ userName: string },这正是我们希望的。
那如果在这里使用了any或者unknown,结果又会怎样呢?

// 使用 any
function timeout(ms: number): Promise<any> {}

// { userName: string } | any => any,失去了类型检查
async function fetchNameWithTimeout(userId: string): Promise<string> {
  return data.userName; // ❌ data 被推断为 any
}

any 很好理解,虽然能正常通过,但相当于没有类型检查了。

// 使用 unknown
function timeout(ms: number): Promise<unknown> {}

// { userName: string } | unknown => unknown,类型被模糊
async function fetchNameWithTimeout(userId: string): Promise<string> {
  return data.userName; // ❌ data 被推断为 unknown
}

unknown则是模糊了类型,需要我们手动去收窄类型。
当我们严格使用never来描述「unreachable code」时,编译器便能够帮助我们准确地收窄类型,做到代码即文档。

条件类型中使用

我们经常在条件类型中见到never,它被用于表示else的情况。

type Arguments<T> = T extends (...args: infer A) => any ? A : never
type Return<T> = T extends (...args: any[]) => infer R ? R : never

对于上述推导函数参数和返回值的两个条件类型,即使传入的T是非函数类型,我们也能够得到编译器的提示:

// Error: Type '3' is not assignable to type 'never'
const x: Return<"not a function type"> = 3;

在收窄联合类型时,never也巧妙地发挥了它作为最小因子的作用。比如说下面这个从T中排除nullundefined的例子:

type NullOrUndefined = null | undefined
type NonNullable<T> = T extends NullOrUndefined ? never : T

// 运算过程
type NonNullable<string | null> 
  // 联合类型被分解成多个分支单独运算
  => (string extends NullOrUndefined ? never : string) | (nullextends  NullOrUndefined ? never : null)
  // 多个分支得到结果,再次联合
  => string | never
  // never 在联合类型运算中被消解
  => string

Exhaustive Check

Example1:

interface Shape {
  kind: "cirle" | "square",
  redius?: number
  sideLength?: number
}

function getAreaOne (shape: Shape) {
  // 报错
  return Math.PI * shape.redius * 2
}

function getAreaTwo (shape: Shape) {
  // 窄化还是报错
  if (shape.kind === 'cirle') return Math.PI * shape.redius * 2
}

function getAreaThree (shape: Shape) {
  // 利用非空断言可以(谨慎使用非空断言)
  if (shape.kind === 'cirle') return Math.PI * shape.redius! * 2
}

Example2:

interface Circle {
  kind: "cirle";
  redius: number;
}

interface Square {
  kind: "square";
  sideLength: number;
}

type Shape = Circle | Square;

const getAreaOne = (shape: Shape) => shape.kind === "cirle" && Math.PI * shape.redius * 2;

function getAreaTwo(shape: Shape) {
  switch (shape.kind) {
    case "cirle":
      return Math.PI * shape.redius * 2;
    case "square":
      return shape.sideLength * 2;
    default:
      const _example: never = shape;
      return _example;
  }
}

参考文章:
TypeScript学习笔记之类型窄化篇
TypeScript is 关键字
Narrowing