- 发布于
TypeScript 类型系统技巧
AI 辅助翻译自英文阅读英文原文
- 作者

- 姓名
- Garfield Zhu
- @_AlohaYo_
@Author: Garfield Zhu
类型系统
TypeScript 拥有极其强大而精巧的类型系统。让我们善用类型工具,充分发挥它的能力。
Infer(类型推断)
infer 关键字是构建类型时最强大的工具之一,可以从嵌套类型中提取任意类型。
例如,提取函数参数的类型列表
type Param<T extends (...args: any[]) => any> =
T extends (...args: infer A) => any
? A
: never
const foo = (a: number, b: string) => true
const bar = (a: boolean, b: number, c: number) => false
type t1 = Param<typeof foo> // [number, string]
type t2 = Param<typeof bar> // [boolean, number, number]
类型构造器 / 类型工具
TypeScript 允许基于旧类型构建新类型,并通过类型推断从已有类型生成类型。
类型构造器非常有用,可以借助类型工具构建更强大、更可靠的类型。
内置工具类型
TypeScript 包含多个实用的类型工具:
请参阅官方工具类型。
下面是一些非常实用的类型:
Partial<T>
假设
T= { a: string, b: number } 是包含多个参数的接口,我们希望构建一个所有属性都可选的类型。因此得到
Partial<T>= { a?: string, b?: number }Required<T>
与
Partial相反。Pick<T, K>
假设
T= { a: string, b: number, c: boolean[] },只从中挑选部分属性。可以得到
Pick<T, 'c'>= { c: boolean }, 或者Pick<T, 'a', 'b'>= { a: string, b: number }ReturnType<T>
获取函数或 lambda 类型的返回类型。
假设
T= (...args: any[]) => { foo: number, bar: string },则
ReturnType<T>= { foo: number, bar: string }等等。
第三方扩展类型工具库
一些非常实用的类型工具,其中部分如今已成为 TypeScript 内置工具。
实用的自定义类型
借助 typeof、keyof、infer 等关键字,我们几乎可以从已有类型扩展、派生出任意类型。
在具体场景中,可以定义自定义类型来解决问题:
React.js
TypeScript 有助于为 React 正确标注 props 类型。
PropType<T, K>
给定组件的 props 类型,获取某个属性的类型。(如果上层组件需要该属性,这会非常有用。)
/** Given a object type, and a property name, get the type of that object property * Example: * type Foo = { a: number, b: { x: string[], y: (y: string) => boolean }, c: () => void } * type A = PropType<Foo, 'a'> // number * type B = PropType<Foo, 'b'> // { x: string[], y: (y: string) => boolean } * type C = PropType<Foo, 'c'> // () => void * type Bx = PropType<PropType<Foo, 'b'>, 'x'> // string[] * type By = PropType<PropType<Foo, 'b'>, 'y'> // (y: string) => boolean */ export type PropType<TObj, TProp extends keyof TObj> = TObj[TProp]ReactFCPropsType<T, K>
给定第三方 React 组件库,如果库没有导出组件 props 类型但我们确实需要它,可能会遇到问题。
在代码中为第三方 props 手动定义类型,库更新接口时可能产生兼容性问题。我们可以直接推断类型,以便编译时发现类型不匹配。
/** Given a type of React function component, get the type of the props of it. * It could be used if some 3rd party component does not externally export the props type, but we need it. * (Use `typeof` to assign the type of React.FC component as generic parameter) * Example: * ``` * import { Tree, Button } from 'antd' * * type TreeProps = ReactFCPropsType<typeof Tree> // The type of the props required by Tree * type ButtonProps = ReactFCPropsType<typeof Button> // The type of the props required by Button * ``` */ export type ReactFCPropsType<TReactFC> = TReactFC extends (props: infer U, ...args: any[]) => any ? U : never
协变与逆变
1. Java 问题
请看下面的示例:
- 能否编译?
- 能否运行?
- 哪一行会报告错误?
- 哪一行报告错误更合适?
public static void f() {
String[] a = new String[2];
Object[] b = a;
a[0] = "hi";
b[1] = Integer.valueOf(42);
}
答案
public static void f() {
String[] a = new String[2];
Object[] b = a;
a[0] = "hi";
b[1] = Integer.valueOf(42); // <--- Runtime exception: java.lang.ArrayStoreException
}
为什么不这样做?
public static void f() {
String[] a = new String[2];
Object[] b = a; // ~~~~~~~ Compile error: "a": String[] could not be assigned to "b": Object[]
a[0] = "hi";
b[1] = Integer.valueOf(42);
}
这里是 Object 数组,为什么 Object 是正确的?
public static void f() {
String a = new String();
Object b = a; // Typical polymorphism
a = "hi";
b = Integer.valueOf(42); // Awesome!
}
有什么区别?
- 数组包含多个值?
b[0]和b[1]不能拥有不同类型?- 我们说“String[] 不应赋给 Object[]”,为什么“将 String 赋给 Object”却是面向对象的核心?
答案
- 在面向对象中,String 通常被视为 Object 的子类型。
- 将子类型赋给超类型总是正确的。
- 但请注意!String[] 并不是 Object[] 的子类型!
总之,只要知道 A 是 B 的子类型,无论 T 如何定义,都可以说 T[A] 是 T[B] 的子类型。
2. 协变、逆变、双变与不变
协变与逆变描述类型计算后的类型关系。
在 TypeScript 中,我们使用
type关键字和泛型从已有类型构造新类型,这称为类型构造器。例如:
type C<T> = T[]这只发生在编译时,在 JavaScript 运行时会完全消失。
我们说类型
C定义了从 T 到 C<T> 的集合映射。这种类型映射或构造
C之间的子类型关系称为“变性”。给定集合
A是B的子类型/子集,在数学中记作A ⊆ B:变性描述映射集合
C<A>与C<B>之间的关系。数学中使用
⊆、⊇表示子集/超集关系;计算机科学中使用<:表示子类型。协变(Covariance)
保持子集关系。
A <: B=>C<A> <: C<B>逆变(Contravariance)
反转关系,原来的子集被构造成超集。
A <: B=>C<A> :> C<B>双变(Bivariance)
两种关系同时适用。
A <: B=>C<A> <: C<B>ANDC<A> :> C<B>不变(Invariance)
映射后不再具有变性。
A <: B=>C<A> ⊄ C<B>ANDC<B> ⊄ C<A>
3. 数组中的协变
回到上面的问题:为什么 String[] 不是 Object[] 的子类型?
是协变、逆变还是不变?
在 Java 中这是协变:允许将
String[]赋给Object[]。但显然,当数组可写时这样做并不正确。
如果数组是只读的,那么协变就是正确的。
/* readonly*/ Object[] a = new String[] {"foo", "bar", "test"}; System.out.println(a[0], a[1], a[2]);
答案
可读写数组应当是***不变(INVARIANT)***的。
这就是为什么
String[]不是Object[]的子类型。只读数组是**协变(covariant)**的。
相反,可以说
readonly String[]是readonly Object[]的子类型。
扩展
这就是 Java(以及 C#)中典型的静态类型问题。
猜猜为什么?
根本原因
没错,是泛型(GENERICS)。早期的 Java 和 C# 不支持泛型。
它们使用父类型(类似泛型边界)来让函数接受更通用的类型。
boolean equalArrays (Object[] a1, Object[] a2); // equal function should be readonly, which is safe. void shuffleArray(Object[] a);- 它应当这样定义。
<T extends Comparable<T>> boolean equalArrays (T[] a1, T[] a2); <T> void shuffleArray(T[] a);如今,这项历史遗留特性已经成了负担。
使用时必须确认数组是否可写,以避免运行时错误。
或者改用不可变/只读数组,而不是原始对象数组。(当然,在 Java/C# 引入原生不可变数据类型之前,这会带来额外开销。)
在 C# 中:
IEnumerable<object> // replace "object[]"在 Java 中:
List<Object> items = Collections.unmodifiableList(Arrays.asList("a", "b", "c"));
4. 函数类型中的协变
函数类型的正确行为是:
返回类型是协变的。
给定
A<:B,有() => A<:() => B。参数类型是逆变的。
给定
A<:B,有(a: A) => void:>(b: B) => void。上述规则共同生效。
给定
A<:B且C<:D,有
(b: B) => C:>(a: A) => D。
参见函数类型示例,以及 React FC 示例。
TS 配置项 strictFunctionType 控制函数参数按协变还是逆变处理。
5. 继承中的协变
在面向对象语言(cpp、Java、C# 等)中,OVERRIDE 是子类实现与父类不同方法的关键概念。
我们知道重写应保持相同的方法签名,但某些语言也允许协变。
协变的方法返回类型
在函数部分我们已经知道,返回类型是协变的(给定
A<:B,则有() => A<:() => B。)在继承中,使用子类型方法重写方法属于继承中的协变。(Java 和 C++ 支持这一点,C# 不支持。)
class Animal { Animal getAnimal() { // ... } } // Child class class Cat extends Animal { @overrides Cat getAnimal() { ... } }逆变的方法参数类型
与上一节类似,我们可以推测,方法参数类型的逆变同样是一种类型安全的重写。
是的,它是类型安全的。
但很少有语言实现这一点。😅
在 Java、C++ 和 C# 中,它会被视为重载,而不是重写。
class Animal { void setAnimal(Animal a) { // ... } } // Child class class Cat extends Animal { // It's still correct. But it's not overriding. It's a overloading in Java. // This method could not be hit by a call of "cat.setAnimal(animal)" unless `animal` is not an instance of Animal. void setAnimal(Object a) { // ... } }
6. 泛型中的协变
泛型类型主要有两种处理方式:
- 声明处变性标注(C#)
- 使用处变性标注(Java)
由以上各节可知:
- 输入参数类型的泛型应该是协变的。
- 输出参数类型的泛型应该是逆变的。
声明处变性标注
C# 使用 in(协变)和 out(逆变)关键字标记类型。
interface IEnumerator<out T>
{
T Current { get; }
bool MoveNext();
}
如果将 out T 用作输入参数类型,声明接口时会报告错误。
Scala 使用 +(协变)和 -(逆变)作为关键字。
sealed abstract class List[+A] extends AbstractSeq[A] {
def head: A
def tail: List[A]
/** Adds an element at the beginning of this list. */
def ::[B >: A] (x: B): List[B] =
new scala.collection.immutable.::(x, this)
/** ... */
}
使用处变性标注
泛型类型实例化时会检查变性。
给定 type A<T> = T,当 Test 不满足 T 的要求而实例化 A<Test> 时应报告错误。
一种典型实现是“上界/下界约束”。
在 Java 中
Java 中有
extends和super这两个边界描述符。// Lower bounds is very common in the languages support generic List<? extends Animal> // Upper bounds is not common, Java uses "super" keyword List<? super Animal>在 TypeScript 中
截至目前,TypeScript 尚不支持泛型上界约束。相关开放问题见:TypeScript#9252
不过,借助现有的 TS 类型工具,可以通过
Partial<T>等方式绕过并支持上界。请参阅这里讨论的案例,思考一下:
Partial<T>是否等价于<S super T>?
7. 示例
在 React 组件中 推荐使用 React 函数组件。应理解 React props 的变性,以避免不必要的问题。
请看下面的实际示例:问题在哪里?
// React component definition: type Elem = { id: string; } type Props = { elem: Elem; generate: () => Elem; onClick: (elem: Elem) => void; } // React FC const MyComp: React.FC<Props> = (props: Props) => { const { elem, onClick, } const guiElement = { ...elem, // Extended GUI properties name: `element - ${elem.id}`, desc: `description for - ${elem.id}`, } // Event handler callback const clickHandler = React.useCallback((_e) => { console.log(generate()) onClick(guiElement) }, []) return <button onClick={clickHandler}> {guiElement.id} </button> }// Use the above component const customElem = { id: '0hd3ga1fa3h2664g', count: 99, name: 'bar', } const g = () => customElem const cb = (e: typeof customElem) => { alert((e as any).name) // ? alert(JSON.stringify(e)) // ? alert(e.name.split(' ')) // ? } ReactDOM.render(<MyComp elem={customElem} // is this correct? generate={g} // is this correct? onClick={cb} // is this correct? />)简化示例见:
在 TypeScript 中将子联合类型作为 React props 假设 React 组件要求联合类型 A | B | C 的 prop,而使用者传入更窄的联合类型 A | C。 这样安全吗?
type Props = { bar: A | B | C } const Foo: React.FC<Props> = (props) => { // ... do with props.bar } // use case: type TestType = A | C const myBar: TestType render(<Foo bar={myBar} />) // ?? Type safe请记住,运行时特性会破坏静态类型信息。
例如 反射 会让类型推断在反射开始处丢失。
反射是 Java 和 C# 中的概念。在动态语言中,它通常自然地用于字面对象。
例如
for (const key in obj) ...它非常有用,但确实是运行时特性,会严重破坏静态类型系统。请在理解其影响的地方使用,并谨慎处理类型。
示例:
// Base type type Base = { id: string; name: string; } // literal object derives the `Base` const obj: Base = { id: 'abc', name: 'test', category: 'foo', item: 'bar', } // JSON stringify method will iterate the runtime instance of `obj`, instead of the part of `Base` const json = JSON.stringify(obj) // JSON.parse is also runtime method, which makes us lost typing information. const restored = JSON.parse(json) /* as Base */
技巧
带有多余属性的接口
由于 TS 使用结构化子类型系统,TypeScript 允许将 { size: number; label: string; } 传给只需要 { label: string; } 的对象。但内联字面对象不允许这样做。
这是由多余属性检查导致的。
试试这个示例
类(名义类型)
这同样由结构化类型导致。如果类 A 和类 B 具有相同成员,那么要求 A 实例的函数也会接受 B 作为合法参数。
试试这个示例
判别联合类型
TypeScript 提供了判别联合类型这一特性。根据文档,判别联合类型有两个要求:
- 具有公共单例类型属性的类型——判别属性。
- 取这些类型并集的类型别名——联合类型。
也就是说:
interface Dog {
kind: "dog"
bark: string
}
interface Cat {
kind: "cat"
meow: string
}
type Animal = Cat | Dog
类型 Animal 应包含 kind 属性。
但如果公共属性是嵌套对象,则无法正常工作,例如:
interface Dog {
taxonomy: {
species: "Canis familiaris"
}
bark: string
}
interface Cat {
taxonomy: {
species: "Felis catus"
}
meow: string
}
试试这个示例