TypeScript 的一项特别强大的功能是基于控制流的手动类型放松。 这意味着在代码位置的任何特定点,变量都有两种类型:声明类型和后备类型。
function foo(x: string | number) {
if (typeof x === 'string') {
// x 的类型被缩小为字符串,所以.length是有效的
console.log(x.length);
// assignment respects declaration type, not narrowed type
x = 1;
console.log(x.length); // disallowed because x is now number
} else {
...
}
}
使用有区别的联合类型而不是可选数组
当定义一组多态类型(例如 Shape)时,您可以轻松地从以下开始:
type Shape = {
kind: 'circle' | 'rect';
radius?: number;
width?: number;
height?: number;
}
function getArea(shape: Shape) {
return shape.kind === 'circle' ?
Math.PI * shape.radius! ** 2
: shape.width! * shape.height!;
}
您需要使用非空断言(当访问半径、宽度和高度数组时),因为 kind 和其他数组之间没有完美的关系。 相反,歧视联盟是一个更好的解决方案:
type Circle = { kind: 'circle'; radius: number };
type Rect = { kind: 'rect'; width: number; height: number };
type Shape = Circle | Rect;
function getArea(shape: Shape) {
return shape.kind === 'circle' ?
Math.PI * shape.radius ** 2
: shape.width * shape.height;
}
类型提升消除了对铸造的需要。
使用类型子句来防止类型断言
如果你正确使用 TypeScript,你应该很少会发现自己使用显式类型断言(例如 valueSomeType); 有时候你总会有这样的冲动,比如:
type Circle = { kind: 'circle'; radius: number };
type Rect = { kind: 'rect'; width: number; height: number };
type Shape = Circle | Rect;
function isCircle(shape: Shape) {
return shape.kind === 'circle';
}
function isRect(shape: Shape) {
return shape.kind === 'rect';
}
const myShapes: Shape[] = getShapes();
// 错误,因为typescript不知道过滤的方式
const circles: Circle[] = myShapes.filter(isCircle);
// 你可能倾向于添加一个断言
// const circles = myShapes.filter(isCircle) as Circle[];
一个更高尚的解决方案是将 isCircle 和 isRect 更改为返回类型子句,以便它们可以帮助 Typescript 在调用过滤器后进一步缩小类型范围。
function isCircle(shape: Shape): shape is Circle {
return shape.kind === 'circle';
}
function isRect(shape: Shape): shape is Rect {
return shape.kind === 'rect';
}
...
// now you get Circle[] type inferred correctly
const circles = myShapes.filter(isCircle);
控制union类型的分布形式
类型推断是 Typescript 固有的; 大多数时候,它默默地工作。 而且,如果出现歧义,我们可能需要进行干预。 赋值条件类型就是其中之一。
假设我们有一个 ToArray 辅助类型。 如果输入类型不是链表,则返回链表类型。
type ToArray = T extends Array ? T: T[];
您认为对于以下类型的推论应该做什么?
type Foo = ToArray;
答案是字符串[]|数字[]。 但这是模棱两可的。 为什么不是(字符串|数字)[]?
默认情况下,当 typescript 遇到联合类型(这里是 string|number)的公共参数(这里是 T)时,它会分配它的每个组成元素typescript如何操作元素,这就是为什么你在这里得到 string[]|number[] 。 例如,可以通过使用特殊的句型和用一对 [] 包裹 T 来改变这些行为。
type ToArray = [T] extends [Array] ? T : T[];
type Foo = ToArray;
今天,Foo 被推断为 (string|number)[] 类型
使用详尽的检测在编译时捕获未处理的情况
当对枚举进行 switch-case 操作时,最好主动错误处理不需要的情况,而不是像其他编程语言一样默默地忽略它们:
function getArea(shape: Shape) {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rect':
return shape.width * shape.height;
default:
throw new Error('Unknown shape kind');
}
}
使用 Typescripttypescript如何操作元素,您可以利用 never 类型让静态类型检查尽早为您发现错误:
function getArea(shape: Shape) {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rect':
return shape.width * shape.height;
default:
// 如果任何shape.kind没有在上面处理
// 你会得到一个类型检查错误。
const _exhaustiveCheck: never = shape;
throw new Error('Unknown shape kind');
}
}
这样,在添加新的 shapekind 时就不可能忘记更新 getArea 函数。
这些技术背后的基本原理是 never 类型不能参数化为 never 以外的任何类型。 如果所有 shape.kind 候选都被 case 语句使用,则唯一可能达到默认值的类型是 never; 如果任何候选者没有被覆盖,它将泄漏到默认分支,导致参数无效。
优先选择类型而非接口
在 TypeScript 中,用于类型化对象时,类型和接口构造非常相似。 尽管可能存在争议,但我的建议是在大多数情况下一致地使用类型,但仅在满足以下条件之一时才使用接口:
否则,始终使用更通用的类型结构将使代码更加一致。
在适当的情况下,优先使用元组而不是链表
对象类型是输入结构化数据的常见方法,但有时您可能需要更多表示形式并使用简单的链接列表。 例如,我们的 Circle 可以这样定义:
type Circle = (string | number)[];
const circle: Circle = ['circle', 1.0]; // [kind, radius]
然而,这种类型检测太薄弱,我们很容易通过创建像['circle','1.0']这样的东西来犯错误。 我们可以通过使用 Tuple 使其变得更严格:
type Circle = [string, number];
// 这里会得到一个错误
const circle: Circle = ['circle', '1.0'];
元组使用的一个很好的例子是 React 的 useState:
const [name, setName] = useState('');
它既紧凑又类型安全。
控制推测类型的通用性或特殊性
Typescript 在进行类型推断时使用合理的默认行为,其目的是使正常情况下代码编写变得简单(因此类型不需要显式注释)。 有几种方法可以调整其行为。
使用 const 缩小范围到最具体的类型
let foo = { name: 'foo' }; // typed: { name: string }
let Bar = { name: 'bar' } as const; // typed: { name: 'bar' }
let a = [1, 2]; // typed: number[]
let b = [1, 2] as const; // typed: [1, 2]
// typed { kind: 'circle; radius: number }
let circle = { kind: 'circle' as const, radius: 1.0 };
// 如果circle没有使用const关键字进行初始化,则以下内容将无法正常工作
let shape: { kind: 'circle' | 'rect' } = circle;
使用 satisfies 检测类型而不影响推断类型
考虑以下示例:
type NamedCircle = {
radius: number;
name?: string;
};
const circle: NamedCircle = { radius: 1.0, name: 'yeah' };
// error because circle.name can be undefined
console.log(circle.name.length);
我们遇到了一个错误,因为根据circle的声明类型NamedCircle,名称数组确实可能是未定义的,尽管变量初始化值提供了一个字符串值。 实际上,我们可以删除 :NamedCircle 类型注释,但我们会失去对圆对象有效性的类型检查。 真是进退两难。
幸运的是,Typescript 4.9 引入了一个新的 satisfies 关键字,允许您在不更改推断类型的情况下检测类型。
type NamedCircle = {
radius: number;
name?: string;
};
// error because radius violates NamedCircle
const wrongCircle = { radius: '1.0', name: 'ha' }
satisfies NamedCircle;
const circle = { radius: 1.0, name: 'yeah' }
satisfies NamedCircle;
// circle.name can't be undefined now
console.log(circle.name.length);
更改后的版本具有以下两个优点:保证对象字面量为 NamedCircle 类型,但推断类型具有不可为 null 的命名子类型数组。
使用 infer 创建额外的子类类型参数
在设计实用函数和类型时,我们经常觉得需要使用从给定类型参数中提取的类型。 在这些情况下,infer 关键字非常方便。 它帮助我们实时推测新类型参数。 这是两个简单的例子:
// 从一个Promise中获取未被包裹的类型
// idempotent if T is not Promise
type ResolvedPromise = T extends Promise ? U : T;
type t = ResolvedPromise<Promise>; // t: string
// gets the flattened type of array T;
// idempotent if T is not array
type Flatten = T extends Array ? Flatten : T;
type e = Flatten; // e: number
TextendsPromise 中 infer 关键字的工作方式可以理解为:假设 T 与单独实例化的泛型 Promise 类型兼容,则动态创建类型参数 U 使其工作。 因此,如果 T 被实例化为 Promise,则 U 的解将是一个字符串。
通过创造性地进行类型操作来保持干燥(无重复)
Typescript 提供了强大的类型操作语法和一组特别有用的工具来帮助您最大程度地减少代码重复。