Skip to Content
TypeScript类型系统

类型系统

类型推断

代码可以依赖 TypeScript 编译器实现的类型推断(Type Inference)来处理所有类型表达式(变量、字段、返回类型等)。

const x = 15; // 类型被推断。

对于可以简单推断的类型,省略类型注解:初始化为 stringnumberbooleanRegExp 字面量或 new 表达式的变量或参数。

const x: boolean = true; // 不好:这里的 'boolean' 不助于可读性
// 不好:'Set' 可以从初始化中简单推断出来 const x: Set<string> = new Set();

可能需要显式指定类型以防止泛型类型参数被推断为 unknown。例如,初始化没有值的泛型类型(如空数组、对象、MapSet)。

const x = new Set<string>();

对于更复杂的表达式,类型注解可以帮助提高程序的可读性:

// 没有注解很难推断 'value' 的类型。 const value = await rpc.getSomeValue().transform();
// 一目了然就能知道 'value' 的类型。 const value: string[] = await rpc.getSomeValue().transform();

是否需要注解由代码审查者决定。

返回类型

是否包含函数和方法的返回类型注解由代码作者决定。审查者可以要求添加注解以阐明难以理解的复杂返回类型。项目可以有始终要求返回类型的本地策略,但这不是通用的 TypeScript 风格要求。

显式写出函数和方法的隐式返回值有两个好处:

  • 为代码的读者提供更精确的文档。
  • 如果将来有改变函数返回类型的代码变更,可以更快地暴露潜在的类型错误。

undefined 和 null

TypeScript 支持 undefinednull 类型。可空类型可以构造为联合类型(string|null);undefined 也类似。undefinednull 的联合没有特殊语法。

TypeScript 代码可以使用 undefinednull 来表示值的缺失,没有通用指导来优先选择其中之一。许多 JavaScript API 使用 undefined(例如 Map.get),而许多 DOM 和 Google API 使用 null(例如 Element.getAttribute),因此合适的缺失值取决于上下文。

可空/undefined 类型别名

类型别名禁止在联合类型中包含 |null|undefined。可空别名通常表明 null 值在应用程序的太多层中被传递,这模糊了导致 null 的原始问题的来源。它们还使得不清楚类或接口上的特定值何时可能缺失。

相反,代码必须只在实际使用别名时才添加 |null|undefined。代码应该在值接近产生的地方处理 null 值,使用上述技术。

// 不好 type CoffeeResponse = Latte|Americano|undefined; class CoffeeService { getLatte(): CoffeeResponse { ... }; }
// 更好 type CoffeeResponse = Latte|Americano; class CoffeeService { getLatte(): CoffeeResponse|undefined { ... }; }

优先使用可选而非 |undefined

此外,TypeScript 支持使用 ? 来构造可选参数和字段的特殊语法:

interface CoffeeOrder { sugarCubes: number; milk?: Whole|LowFat|HalfHalf; } function pourCoffee(volume?: Milliliter) { ... }

可选参数隐式地在其类型中包含 |undefined。然而,它们的不同之处在于,在构造值或调用方法时可以省略它们。例如,{sugarCubes: 1} 是有效的 CoffeeOrder,因为 milk 是可选的。

使用可选字段(在接口或类上)和参数,而不是 |undefined 类型。

对于类,尽量完全避免这种模式,并尽可能多地初始化字段。

class MyClass { field = ''; }

使用结构化类型

TypeScript 的类型系统是结构化的(Structural),而非名义化的(Nominal)。也就是说,如果一个值至少具有类型所需的所有属性,且属性的类型递归匹配,则该值与该类型匹配。

在提供基于结构的实现时,在符号的声明处显式包含类型(这允许更精确的类型检查和错误报告)。

const foo: Foo = { a: 123, b: 'abc', }
const badFoo = { a: 123, b: 'abc', }

使用接口来定义结构化类型,而不是类

interface Foo { a: number; b: string; } const foo: Foo = { a: 123, b: 'abc', }
class Foo { readonly a: number; readonly b: number; } const foo: Foo = { a: 123, b: 'abc', }

为什么?

上面的 “badFoo” 对象依赖类型推断。可以向 “badFoo” 添加额外的字段,类型将根据对象本身进行推断。

当将 “badFoo” 传递给接受 “Foo” 的函数时,错误将出现在函数调用处,而不是对象声明处。这在跨大型代码库更改接口表面时也很有用。

interface Animal { sound: string; name: string; } function makeSound(animal: Animal) {} /** * 'cat' 的推断类型为 '{sound: string}' */ const cat = { sound: 'meow', }; /** * 'cat' 不满足函数所需的类型契约,所以 * TypeScript 编译器会在这里报错,这可能离 'cat' 定义的地方很远。 */ makeSound(cat); /** * Horse 有结构化类型,类型错误会在这里显示而不是 * 函数调用处。'horse' 不满足 'Animal' 的类型契约。 */ const horse: Animal = { sound: 'niegh', }; const dog: Animal = { sound: 'bark', name: 'MrPickles', }; makeSound(dog); makeSound(horse);

优先使用接口而非类型字面量别名

TypeScript 支持类型别名(Type Alias) 来命名类型表达式。这可以用于命名原始类型、联合类型、元组类型和其他任何类型。

然而,在声明对象的类型时,使用接口(Interface)而非对象字面量表达式的类型别名。

interface User { firstName: string; lastName: string; }
type User = { firstName: string, lastName: string, }

为什么?

这两种形式几乎等价,因此根据在两种形式中选择一种以防止变异的原则,我们应该选择一种。此外,还有有趣的技术原因优先使用接口 。该页面引用了 TypeScript 团队负责人的话:“老实说,我的看法是,凡是接口能建模的就应该使用接口。当存在这么多关于显示/性能的问题时,类型别名没有任何好处。”

Array<T> 类型

对于简单类型(仅包含字母数字字符和点号),使用数组的语法糖形式 T[]readonly T[],而非较长的形式 Array<T>ReadonlyArray<T>

对于简单类型的多维非 readonly 数组,使用语法糖形式(T[][]T[][][] 等)而非较长的形式。

对于更复杂的情况,使用较长的形式 Array<T>

这些规则适用于每一层嵌套,即嵌套在更复杂类型中的简单 T[] 仍然使用语法糖形式拼写为 T[]

let a: string[]; let b: readonly string[]; let c: ns.MyObj[]; let d: string[][]; let e: Array<{n: number, s: string}>; let f: Array<string|number>; let g: ReadonlyArray<string|number>; let h: InjectionToken<string[]>; // 对嵌套类型使用语法糖。 let i: ReadonlyArray<string[]>; let j: Array<readonly string[]>;
let a: Array<string>; // 语法糖更短。 let b: ReadonlyArray<string>; let c: Array<ns.MyObj>; let d: Array<string[]>; let e: {n: number, s: string}[]; // 大括号使其更难阅读。 let f: (string|number)[]; // 括号也是。 let g: readonly (string | number)[]; let h: InjectionToken<Array<string>>; let i: readonly string[][]; let j: (readonly string[])[];

可索引类型 / 索引签名({[key: string]: T}

在 JavaScript 中,将对象用作关联数组(也称为 “map”、“hash” 或 “dict”)是很常见的。这种对象可以在 TypeScript 中使用索引签名(Index Signature) [k: string]: T)来标注类型:

const fileSizes: {[fileName: string]: number} = {}; fileSizes['readme.txt'] = 541;

在 TypeScript 中,为键提供有意义的标签。(标签仅用于文档目的;在其他方面不使用。)

const users: {[key: string]: number} = ...;
const users: {[userName: string]: number} = ...;

与其使用上述方式,不如考虑使用 ES6 的 MapSet 类型。JavaScript 对象有令人意外的不良行为 ,而 ES6 类型更明确地传达了你的意图。此外,Map 可以使用——Set 可以包含——string 以外的类型作为键/值。

TypeScript 的内置 Record<Keys, ValueType> 类型允许构造具有已定义键集的类型。这与关联数组的区别在于键是静态已知的。关于此的建议参见下文

映射类型和条件类型

TypeScript 的映射类型(Mapped Type) 条件类型(Conditional Type) 允许基于其他类型指定新类型。TypeScript 的标准库包含几个基于这些的类型运算符(RecordPartialReadonly 等)。

这些类型系统特性允许简洁地指定类型并构建强大且类型安全的抽象。但它们也有一些缺点:

  • 与显式指定属性和类型关系(例如使用接口和扩展,参见下面的示例)相比,类型运算符需要读者在心里求值类型表达式。这会使程序显著更难阅读,特别是在与类型推断和跨文件边界的表达式结合使用时。
  • 映射和条件类型的求值模型,特别是与类型推断结合时,规范不够明确,不总是被很好地理解,并且经常在 TypeScript 编译器版本之间发生变化。代码可能“意外地”编译通过或看起来给出正确的结果。这增加了使用类型运算符的代码的未来支持成本。
  • 映射和条件类型在从复杂和/或推断的类型派生类型时最为强大。另一方面,这也是它们最容易创建难以理解和维护的程序的时候。
  • 某些语言工具对这些类型系统特性支持不佳。例如,你的 IDE 的查找引用(因此也包括重命名属性重构)不会找到 Pick<T, Keys> 类型中的属性,代码搜索也不会为它们创建超链接。

风格建议是:

  • 始终使用能表达你代码的最简单的类型构造。
  • 少量的重复或冗长通常比复杂类型表达式的长期成本便宜得多。
  • 映射和条件类型可以使用,但要考虑上述因素。

例如,TypeScript 的内置 Pick<T, Keys> 类型允许通过子集化另一个类型 T 来创建新类型,但简单的接口扩展通常更容易理解。

interface User { shoeSize: number; favoriteIcecream: string; favoriteChocolate: string; } // FoodPreferences 有 favoriteIcecream 和 favoriteChocolate,但没有 shoeSize。 type FoodPreferences = Pick<User, 'favoriteIcecream'|'favoriteChocolate'>;

这等价于在 FoodPreferences 上拼出属性:

interface FoodPreferences { favoriteIcecream: string; favoriteChocolate: string; }

为了减少重复,User 可以扩展 FoodPreferences,或者(可能更好)为食物偏好嵌套一个字段:

interface FoodPreferences { /* 如上 */ } interface User extends FoodPreferences { shoeSize: number; // 也包含偏好。 }

在这里使用接口使属性分组更明确,改善了 IDE 支持,允许更好的优化,并且可以说使代码更容易理解。

any 类型

TypeScript 的 any 类型是所有其他类型的超类型和子类型,并允许解引用所有属性。因此,any 是危险的——它可以掩盖严重的编程错误,并且其使用破坏了首先使用静态类型的价值。

**考虑不要使用 any。**在你想使用 any 的情况下,考虑以下替代方案:

提供更具体的类型

使用接口、内联对象类型或类型别名:

// 使用声明的接口来表示服务端 JSON。 declare interface MyUserJson { name: string; email: string; } // 使用类型别名来命名重复书写的类型。 type MyType = number|string; // 或者对复杂的返回值使用内联对象类型。 function getTwoThings(): {something: number, other: string} { // ... return {something, other}; } // 使用泛型类型,否则库会使用 `any` 来表示 // 它们不关心用户操作的是什么类型(但请注意下面的"仅返回类型泛型")。 function nicestElement<T>(items: T[]): T { // 在 items 中找到最好的元素。 // 代码也可以对 T 加约束,例如 <T extends HTMLElement>。 }

使用 unknown 而非 any

any 类型允许赋值给任何其他类型并解引用其上的任何属性。通常这种行为不是必要的或期望的,代码只需要表达类型是未知的。在这种情况下使用内置类型 unknown——它表达了这个概念,并且更安全,因为它不允许解引用任意属性。

// 可以赋任何值(包括 null 或 undefined),但如果不 // 缩窄类型或转换就不能使用。 const val: unknown = value;
const danger: any = value /* 任意表达式的结果 */; danger.whoops(); // 此访问完全未经检查!

要安全使用 unknown 值,请使用类型守卫(Type Guard) 来缩窄类型

抑制 any lint 警告

有时使用 any 是合理的,例如在测试中构造模拟对象。在这种情况下,添加一条注释来抑制 lint 警告,并说明为什么它是合理的。

// 此测试只需要 BookService 的部分实现,如果 // 我们遗漏了什么,测试会以明显的方式失败。 // 这是一个故意的不安全的部分模拟 // tslint:disable-next-line:no-any const mockBookService = ({get() { return mockBook; }} as any) as BookService; // 此测试中未使用购物车 // tslint:disable-next-line:no-any const component = new MyComponent(mockBookService, /* unused ShoppingCart */ null as any);

{} 类型

{} 类型,也称为*空接口(Empty Interface)*类型,表示一个没有属性的接口。空接口类型没有指定的属性,因此任何非空值都可以赋值给它。

let player: {}; player = { health: 50, }; // 允许。 console.log(player.health) // 类型 '{}' 上不存在属性 'health'。
function takeAnything(obj:{}) { } takeAnything({}); takeAnything({ a: 1, b: 2 });

Google3 代码不应该在大多数用例中使用 {}{} 表示任何非空的原始值或对象类型,这很少是合适的。优先使用以下更具描述性的类型之一:

  • unknown 可以持有任何值,包括 nullundefined,通常更适合不透明的值。
  • Record<string, T> 更适合字典式对象,通过对包含的值的类型 T(本身可以是 unknown)进行显式声明来提供更好的类型安全。
  • object 也排除了原始类型,只留下非空的函数和对象,但不对可用的属性做任何其他假设。

元组类型

如果你想要创建一个 Pair 类型,请改用元组类型(Tuple Type):

interface Pair { first: string; second: string; } function splitInHalf(input: string): Pair { ... return {first: x, second: y}; }
function splitInHalf(input: string): [string, string] { ... return [x, y]; } // 使用方式: const [leftHalf, rightHalf] = splitInHalf('my string');

然而,通常为属性提供有意义的名称更加清晰。

如果声明 interface 太重,可以使用内联对象字面量类型:

function splitHostPort(address: string): {host: string, port: number} { ... } // 使用方式: const address = splitHostPort(userAddress); use(address.port); // 你也可以使用解构来获得类似元组的行为: const {host, port} = splitHostPort(userAddress);

包装类型

有几个与 JavaScript 原始值相关的类型不应该使用:

  • StringBooleanNumber 与对应的原始类型 stringbooleannumber 有略微不同的含义。始终使用小写版本。
  • Object{}object 都有相似之处,但略微宽松。使用 {} 表示包含除 nullundefined 以外的所有内容的类型,或使用小写 object 进一步排除其他原始类型(上面提到的三种,加上 symbolbigint)。

此外,永远不要将包装类型作为构造函数调用(使用 new)。

仅返回类型泛型

避免创建具有仅返回类型泛型(Return Type Only Generic)的 API。使用具有仅返回类型泛型的现有 API 时,始终显式指定泛型。

Last updated on