likes
comments
collection
share

TypeScript基础之类型拓宽(Type Widening)

作者站长头像
站长
· 阅读数 37

前言

文中内容都是参考https://mariusschulz.com/blog/literal-type-widening-in-typescript 以及 https://github.com/danvk/effective-typescript 内容。

类型拓宽(Type Widening)

拓宽的字面量类型(Widening Literal Types)

const关键字声明的变量 当你用const关键字声明了一个变量,并且初始化了一个字面量值, TypeScript会将其推断为字面量类型:

const stringLiteral = "https"; 
// const stringLiteral: "https"

const numericLiteral = 42; 
// const numericLiteral: 42

const booleanLiteral = true;  
// const booleanLiteral: true

booleanLiteral = false;
// Cannot assign to 'booleanLiteral' because it is a constant.

因为有const关键字,上面的每一个变量值都不能进行修改, 所以推断为字面量类型是非常合适的。 它保留了赋值的准确类型信息。 对于null, undefined也是如此:

const a = null;
// const a: null

const b = undefined;
// const b: undefined

let关键字声明的变量 如果你将上面的这些常量赋值给let声明的变量, 每一个字面量类型会被拓宽为相应的拓宽类型:

let widenedStringLiteral = stringLiteral; 
// let widenedStringLiteral: string

let widenedNumericLiteral = numericLiteral; 
// let widenedNumericLiteral: number

let widenedBooleanLiteral = booleanLiteral;
// let widenedBooleanLiteral: boolean

widenedBooleanLiteral = 1;     // 没毛病 

const关键字声明的变量不同, let关键字声明的变量初始化之后还是可以被修改的。 如果 Typescript 将每一个 let 变量都推断为字面量类型,那么之后如果想给它赋值初始值以外的值都会导致编译时报错。

基于这个原因, let关键字声明的变量会被推断为拓宽后的类型。

对于null, undefined

let a = null;
// let a: any

let b = undefined;  
// let b: any

const c = null;
// const c: null

let x = c;
// let x: null

let y = b;
// let y: undefined

let z = a;
// let z: null

通过 letvar 定义的变量如果满足未显式声明类型注解且被赋予了 nullundefined 值,则推断出这些变量的类型是 any

枚举类型 对于枚举类型同样也如此:

enum FlexDirection {
  Row,
  Column,
}

const enumLiteral = FlexDirection.Row;
// const enumLiteral: FlexDirection.Row

let widenedEnumLiteral = enumLiteral;
// let widenedEnumLiteral: FlexDirection

总结一下字面量类型拓展的规则:

  • 字符串字面量类型会被拓宽为字符串类型
  • 数字字面量类型会被拓宽为数字类型
  • 布尔字面量类型会被拓宽为布尔类型
  • 枚举字面量类型会被拓宽为枚举类型

假设你正在编写一个向量库,你首先定义了一个 Vector3 接口,然后定义了 getComponent 函数用于获取指定坐标轴的值:

interface Vector3 {
  x: number;
  y: number;
  z: number;
}

function getComponent(vector: Vector3, axis: "x" | "y" | "z") {
  return vector[axis];
}

但是,当你尝试使用 getComponent 函数时,TypeScript 会提示以下错误信息:

let x = "x";
// let x: string

let vec = { x: 10, y: 20, z: 30 };
// let vec: {
//   x: number;
//   y: number;
//   z: number;
// };


getComponent(vec, x); 
// 类型“string”的参数不能赋给类型“"x" | "y" | "z"”的参数

很明显, 因为变量x的类型被推断为string类型, 而getComponent函数期望它的第二个参数有一个更具体的类型。 这在实际场合被拓宽了, 所以导致了一个错误。 TypeScript 提供了一些控制拓宽过程的方法。其中一种方法是使用 const。如果用 const 而不是 let 声明一个变量,那么它的类型会更窄。 使用const修复前面例子中的错误:

const x = "x";
// const x: "x"

let vec = { x: 10, y: 20, z: 30 };
// let vec: {
//   x: number;
//   y: number;
//   z: number;
// };


getComponent(vec, x);  // 没毛病

因为 x 不能重新赋值,所以 TypeScript 可以推断更窄的类型,就不会在后续赋值中出现错误。因为字符串字面量型 “x” 可以赋值给 “x”|”y”|”z”,所以代码会通过类型检查器的检查。 然而,const 并不是万灵药。对于对象和数组,仍然会存在问题。

const mixed = ['x', 1];  

mixed变量类型应该是什么? 这里有一些可能性:

  • ('x' | 1)[]
  • ['x', 1]
  • [string, number]
  • readonly [string, number]
  • (string | number)[]
  • readonly (string|number)[]
  • [any, any]
  • any[]

没有更多的上下文,TypeScript 无法知道哪种类型是 "正确的" 。对于以下代码,在JS中是没问题的:

const v = {
  x: 1,
};
v.x = 3;  // OK
v.x = '3'; 
v.y = 4; 
v.name = 'Pythagoras';

而在TypeScript中, 对于v的类型来说: 可能是

  • { readonly x: 1}
  • {x: number}
  • {[key: string]: number}

对于对象,TypeScript 的拓宽算法会将其内部属性视为将其赋值给 let 关键字声明的变量,进而来推断其属性的类型。因此 v 的类型为 {x:number} 。这使得你可以将 obj.x 赋值给其他 number 类型的变量,而不是 string 类型的变量,并且它还会阻止你添加其他属性。

const v = {
  x: 1,
};
// const v: {
//   x: number;
// };

v.x = 3; // 没毛病

v.x = "3";
// 不能将类型“"3"”分配给类型“number”

v.y = 4;
// 类型“{ x: number; }”上不存在属性“y”

v.name = "Pythagoras";
// 类型“{ x: number; }”上不存在属性“name”

其他 函数的形参

let strFun = (str = "this is string") => str;
// let strFun: (str?: string) => string

const specifiedStr = "this is string";
// const specifiedStr: "this is string"

let str2 = specifiedStr;
// let str2: string

let strFun2 = (str = specifiedStr) => str;
// let strFun2: (str?: string) => string

总结一下: 所有通过 let 或 var 定义的变量、函数的形参、对象的非只读属性 ,如果满足指定了初始值且未显式添加类型注解的条件,那么它们推断出来的类型就是指定的初始值字面量类型拓宽后的类型,这就是字面量类型拓宽。

如何覆盖 TypeScript 的默认行为?TypeScript 试图在具体性和灵活性之间取得平衡。它需要推断一个足够具体的类型来捕获错误,但又不能推断出错误的类型。它通过属性的初始化值来推断属性的类型,当然有几种方法可以覆盖 TypeScript 的默认行为。

  1. 提供显式类型注释:

    const v: { x: 1 | 3 | 5 } = {
      x: 1,
    };
    // const v: {
    //   x: 1 | 3 | 5;
    // };
  2. 使用const断言 当你在一个值之后使用 const 断言时,TypeScript 将为它推断出最窄的类型,没有拓宽。

    const v1 = {
      x: 1,
      y: 2,
    };
    // const v1: {
    //   x: number;
    //   y: number;
    // };
    
    const v2 = {
      x: 1 as const,
      y: 2,
    };
    // const v2: {
    //   x: 1;
    //   y: number;
    // };
    
    const v3 = {
      x: 1,
      y: 2,
    } as const;
    // const v3: {
    //   readonly x: 1;
    //   readonly y: 2;
    // };
    

    对数组使用const断言:

    const a1 = [1, 2, 3];  
    // const a1: number[]
    
    const a2 = [1, 2, 3] as const;
    // const a2: readonly [1, 2, 3]

    如果你认为类型拓宽导致了错误,那么可以考虑添加一些显式类型注释或使用 const 断言。

非拓宽的字面量类型(Non-Widening Literal Types)

你可以显式地给一个变量标注字面量类型来新建一个非拓宽字面量类型的变量:

const stringLiteral: "https" = "https"; 
// const stringLiteral: "https" (non-widening)

const numericLiteral: 42 = 42; 
// const stringLiteral: "https"  (non-widening)

当把一个非拓宽字面量类型的变量赋值给另一个变量的时候,字面量类型不会被拓宽:

let widenedStringLiteral = stringLiteral; 
// let widenedStringLiteral: "https" (non-widening)

let widenedNumericLiteral = numericLiteral; 
// let widenedNumericLiteral: 42 (non-widening)

注意,类型依然是 https42。和之前不一样,之前会被分别拓宽为 stringnumber 类型。

非拓宽字面量类型的用处

以下例子中, 使用了两个拓宽后的字符串字面量类型的变量构建了一个数组:

const http = "http"; 
// const http: "http" (widening)

const https = "https"; 
// const https: "https" (widening)

const protocols = [http, https]; 
// const protocols: string[]

const first = protocols[0]; 
// const first: string

const second = protocols[1];
// const second: string

Typescript 会将protocols推断为 string[]。因此,数组的元素 firstsecond 都会被推断为 string 类型。httphttps 的字面量类型信息在拓宽的过程中丢失了。

让我们再显式地将这两个常量标注为 http 和 https 类型:

const http: "http" = "http"; 
// const http: "http" (non-widening)

const https: "https" = "https"; 
// const https: "https" (non-widening)

const protocols = [http, https]; 
// const protocols: ("http" | "https")[]

const first = protocols[0]; 
// const first: "http" | "https"

const second = protocols[1];
// const second: "http" | "https"

此时 protocols 数组会被推断为 ("http" | "https")[],这表示这个数组只能包含字符串 "http" 或者 "https", firstsecond 都被推断为 "http" | "https" 类型, 这是因为数组类型并没有区分索引0和索引1位置的 "http""https" 具体类型,数组只知道元素不管在哪个索引位置,只能包含这两个字面量类型。

如果你想保留数组中字符串字面量类型的位置信息,你可以显式地将这个数组标注为拥有两个元素的元组类型:

const http = "http"; 
// const http: "http" (widening)

const https = "https"; 
// const https: "https" (widening)

const protocols: ["http", "https"] = [http, https]; 
// const protocols: ["http", "https"]

const first = protocols[0]; 
// const first: "http" (non-widening)

const second = protocols[1];
// const second: "https" (non-widening)

现在,firstsecond 被推断为他们各自非拓宽的字符串字面量类型。

最后, 如有错误,欢迎各位大佬指点!感谢!

参考资料

https://mariusschulz.com/blog/literal-type-widening-in-typescript https://github.com/danvk/effective-typescript https://cloud.tencent.com/developer/article/1618836