深入理解 TypeScript 类型系统:11 个高级技巧与最佳实践
本文深入解析 TypeScript 类型系统的核心机制,涵盖将类型视为集合、利用控制流进行类型收窄、使用类型谓词避免断言、控制联合类型分布、穷举式检查、type 与 interface 的选择、元组应用、const 与 satisfies 关键字以及 infer 工具类型等 11 个高级技巧,帮助开发者编写更安全、更简洁的代码。
掌握 TypeScript 的过程往往伴随着认知的重塑。初学者常有一种误解,认为 TypeScript 仅仅是给 JavaScript 添加静态注释,以便编译器辅助发现潜在错误。
这种看法虽大体正确,但并未触及 TypeScript 的核心优势。随着深入使用,你会意识到该语言最强大的能力在于其类型的组合、推断与操纵机制。
本文旨在总结一系列实用技巧,助你充分释放 TypeScript 的类型系统潜力。
将类型视为集合
类型是程序员日常使用的概念,但很难给出简洁的定义。我发现将类型概念化为“集合”有助于直观理解。
例如,初学者常觉得 TypeScript 的类型组合方式反直觉。看一个简单例子:
type Measure = { radius: number };
type Style = { color: string };
// typed { radius: number; color: string }
type Circle = Measure & Style;
如果你将 & 操作符理解为逻辑“与”,可能会误以为 Circle 是一个无效类型,因为它是两个没有重叠字段的类型的交集。但这并非 TypeScript 的工作方式。相反,将其视为集合更容易推导正确行为:
- 每种类型都代表一个值的集合
- 有些集合是无限的,例如 string 和 object;有些则是有限的,例如 boolean 和 undefined。
unknown代表包含所有值的通用集合,而never代表不包含任何值的空集合。Type Measure是一个包含所有拥有名为radius的 number 类型字段的对象的集合。Style同理。&运算符创建交集:Measure & Style表示同时包含radius和color字段的对象集合。这实际上是一个更小的集合,但拥有更多公共字段。- 同样,
|运算符创建并集:这是一个更大的集合,但公共字段可能较少(当两个对象类型组合时)。
集合概念也有助于理解类型分配:只有当源类型是目标类型的子集时,赋值才被允许:
type ShapeKind = 'rect' | 'circle';
let foo: string = getSomeString();
let shape: ShapeKind = 'rect';
// 不允许,因为字符串不是 ShapeKind 的子集。
shape = foo;
// 允许,因为 ShapeKind 是字符串的子集。
foo = shape;
理解类型声明与类型收窄
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!;
}
这需要使用非空断言(在访问 radius、width 和 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 时,你应很少使用显式类型断言(如 value as SomeType);但有时仍会忍不住这样做,例如:
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 在调用 filter 后进一步缩小类型。
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);
控制联合类型的分布行为
类型推断是 TypeScript 的本能;大多数时候它默默工作。但在模糊情况下,我们需要干预,条件类型分配便是其中之一。
假设我们有一个 ToArray 辅助类型,若输入非数组则返回数组类型。
type ToArray<T> = T extends Array<unknown> ? T: T[];
你认为以下类型的推断结果是什么?
type Foo = ToArray<string|number>;
答案是 string[] | number[]。但这存在歧义。为什么不是 (string | number)[] 呢?
默认情况下,当 TypeScript 遇到联合类型(这里是 string | number)作为泛型参数(这里是 T)时,它会分配到每个组成元素,因此得到 string[] | number[]。这种行为可通过特殊语法改变:用一对 [] 包装 T,例如:
type ToArray<T> = [T] extends [Array<unknown>] ? T : T[];
type Foo = ToArray<string | number>;
现在,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');
}
}
利用 TypeScript 的 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');
}
}
这样,在添加新 shape kind 时,就不可能忘记更新 getArea 函数。
该技术的原理是:never 类型除 never 外无法赋值给任何东西。如果所有 shape.kind 候选者都被 case 语句消耗,到达 default 的唯一可能类型即为 never;若有候选者未被覆盖,则会泄漏到 default 分支,导致无效赋值。
优先选择 type 而非 interface
在 TypeScript 中,当用于对对象进行类型定义时,type 和 interface 构造很相似。尽管可能有争议,但我的建议是在大多数情况下一贯使用 type,并且仅在下列情况之一为真时使用 interface:
- 你想利用
interface的 '合并' 功能。 - 你有遵循面向对象风格的代码,其中包含类/接口层次结构。
否则,总是使用更通用的类型结构会使代码更加一致。
在适当的时候优先选择元组而不是数组
对象类型是输入结构化数据的常见方式,但有时你可能希望有更多的表示方法,并使用简单的数组来代替。例如,我们的 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'];
Tuple 使用的一个好例子是 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,name 字段确实可能是 undefined,即使变量初始值提供了字符串值。当然,我们可以删除 :NamedCircle 类型注释,但我们将为 circle 对象的有效性丢失类型检查。相当的困境。
幸运的是,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 类型,并且推断出的类型有一个不可为空的名字字段。
使用 infer 创建额外的泛型类型参数
在设计实用功能和类型时,我们经常会感到需要使用从给定类型参数中提取出的类型。在这种情况下,infer 关键字非常方便。它可以帮助我们实时推断新的类型参数。这里有两个简单的示例:
// 从一个Promise中获取未被包裹的类型
// idempotent if T is not Promise
type ResolvedPromise<T> = T extends Promise<infer U> ? U : T;
type t = ResolvedPromise<Promise<string>>; // t: string
// gets the flattened type of array T;
// idempotent if T is not array
type Flatten<T> = T extends Array<infer E> ? Flatten<E> : T;
type e = Flatten<number[][]>; // e: number
T extends Promise<infer U> 中的 infer 关键字的工作方式可以理解为:假设 T 与某些实例化的通用 Promise 类型兼容,即时创建类型参数 U 使其工作。因此,如果 T 被实例化为 Promise<string>,则 U 的解决方案将是 string。
通过在类型操作方面保持创造力来保持 DRY(不重复)
TypeScript 提供了强大的类型操作语法和一套非常有用的工具,帮助你把代码重复率降到最低。
不是重复声明:
type User = {
age: number;
gender: string;
country: string;
city: string
};
type Demographic = { age: number: gender: string; };
type Geo = { country: string; city: string; };
而是使用 Pick 工具来提取新的类型:
type User = {
age: number;
gender: string;
country: string;
city: string
};
type Demographic = Pick<User, 'age'|'gender'>;
type Geo = Pick<User, 'country'|'city'>;
不是重复函数的返回类型
function createCircle() {
return {
kind: 'circle' as const,
radius: 1.0
}
}
function transformCircle(circle: { kind: 'circle'; radius: number }) {
...
}
transformCircle(createCircle());
而是使用 ReturnType<T> 来提取它:
function createCircle() {
return {
kind: 'circle' as const,
radius: 1.0
}
}
function transformCircle(circle: ReturnType<typeof createCircle>) {
...
}
transformCircle(createCircle());
不是并行地同步两种类型的形状(这里是 typeof config 和 Factory)。
type ContentTypes = 'news' | 'blog' | 'video';
// config for indicating what content types are enabled
const config = { news: true, blog: true, video: false }
satisfies Record<ContentTypes, boolean>;
// factory for creating contents
type Factory = {
createNews: () => Content;
createBlog: () => Content;
};
而是使用 Mapped Type 和 Template Literal Type,根据配置的形状自动推断适当的工厂类型。
type ContentTypes = 'news' | 'blog' | 'video';
// generic factory type with a inferred list of methods
// based on the shape of the given Config
type ContentFactory<Config extends Record<ContentTypes, boolean>> = {
[k in string & keyof Config as Config[k] extends true
? `create${Capitalize<k>}`
: never]: () => Content;
};
// config for indicating what content types are enabled
const config = { news: true, blog: true, video: false }
satisfies Record<ContentTypes, boolean>;
type Factory = ContentFactory<typeof config>;
// Factory: {
// createNews: () => Content;
// createBlog: () => Content;
// }
总结
本文涵盖了 TypeScript 语言中的一组相对高级的主题。在实践中,您可能会发现直接使用它们并不常见;然而,这些技术被专门为 TypeScript 设计的库大量使用:比如 Prisma 和 tRPC。了解这些技巧可以帮助您更好地了解这些工具如何在引擎盖下工作。
以上就是让你更好使用 Typescript 的 11 个技巧分享的详细内容,更多关于 Typescript 技巧的资料请关注本站其它相关文章!
JS split切割后出现空字符串怎么过滤
2026-11-05 16:00:30
在 React 或 Vue 项目中封装使用 JS 节流防抖
2026-11-05 15:27:29
JavaScript中怎么将带小数的字符串转为整数?
2026-11-05 14:32:38
怎么用JavaScript策略模式重构电商促销计算逻辑?
2026-11-05 14:01:09
JS使用constructor判断数据类型可靠吗
2026-11-05 13:49:41








