让代码既严格又灵活的 TypeScript 编码规范
实用的 TypeScript 观点:严格模式、unknown over any、稀疏断言、字符串联合类型、类型与接口的区别、JSDoc 属性说明、实用工具函数以及区分联合类型。
优秀的 TypeScript 应该在避免代码错误交付的同时,不必让每个文件都变得繁琐复杂。以下规范旨在实现更简洁、更安全、更易维护的代码结构,同时保留让 TypeScript 日常使用中依然实用的灵活性。
简述
- 在模块和组件的边界明确定义契约。
- 允许检查工具自动推断私有辅助函数和局部变量。
- 尽量减少且有针对性地使用抑制选项。
- 在创造新别名和工具之前,先复用现有的那些。
- 当状态为多种可能时,优先使用字面量联合类型和带标签的变体。
启用严格模式
始终在 tsconfig.json 中开启严格检查(参见手册):
{
"compilerOptions": {
"strict": true
}
}
严格模式选项能更早地发现常见错误,从而在不断扩大的代码库中保持更高的正确性标准。
避免使用 any 类型
当值的类型不确定时,应优先使用 unknown 而非 any,因为这样在使用前就需要先进行类型限定:
// bad
function processValue(value: any) {
value.toLowerCase(); // No error, but might crash at runtime
}
// good
function processValue(value: unknown) {
if (typeof value === 'string') {
value.toLowerCase(); // OK, we've verified it’s a string
}
}
忽略类型检查
只有在确实需要绕过类型检查时,才应尽量缩小这种“逃生通道”的范围。
使用 as any 对单个表达式进行类型转换通常比使用 @ts-ignore 更为合适,因为前者仅影响该表达式,而后者则可能让整行代码的类型检查都被忽略。
// third-party library with incomplete type definitions
import { someExternalLib } from 'external-lib';
const result = (someExternalLib.complexMethod() as any).undocumentedProperty;
在原生代码中,应优先修复类型问题、添加防护逻辑或验证外部数据(例如使用 Zod),而非直接忽略类型检查器。
当您掌握的信息超出了类型检查器的判断能力时,可使用 @ts-expect-error:
- 与
@ts-ignore不同,如果错误意外消失,编译将会失败。 - 这样能确保有意进行的错误忽略在代码审查中保持透明。
<div
style={{
// @ts-expect-error — vars are valid CSS properties
'--size': `${dimensions.size}px`,
}}
/>
谨慎使用类型断言
as 断言会覆盖检查机制。尽可能优先采用运行时验证:
// bad
const myCanvas = document.getElementById('canvas') as HTMLCanvasElement;
// good
const myCanvas = document.getElementById('canvas');
if (myCanvas instanceof HTMLCanvasElement) {
const ctx = myCanvas.getContext('2d');
}
优先使用类型守卫而非断言
只有运行时检查成功后,类型守卫才会缩小范围——相比直接使用 as,这种方式更具安全性:
type User = {
id: string;
name: string;
};
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof value.id === 'string' &&
'name' in value &&
typeof value.name === 'string'
);
}
function greet(value: unknown) {
if (isUser(value)) {
// value is User here - no assertion needed
console.log(`Hello, ${value.name}`);
}
}
仅当运行时检查不可行或无用时才使用断言——通常是在第三方属性与周围表单结构不一致时:
<OTPInput
// Our form pattern expects a traditional change handler but this third-party
// component provides the text value as its only callback argument. We need
// to manually create an event-like object so validation works as expected
// for consumers.
onChange={(value) => {
props.onChange?.({
target: { value },
} as ChangeEvent<HTMLInputElement>);
}}
/>
优先使用字符串字面量联合而非枚举
对于应用代码而言,字符串字面量联合通常优于枚举:无需创建运行时对象,输出更简单,且更便于序列化:
// bad
enum Environments {
DEV = 'dev',
TESTING = 'test',
STAGING = 'staging',
PRODUCTION = 'prod',
}
// good
type Environments = 'dev' | 'test' | 'staging' | 'prod';
当需要运行时对象、现有 API 已使用枚举,或团队标准要求使用时,才保留枚举。
从值中推断联合类型
当运行时已存在对应值时,应从唯一的真实数据源获取并集:
const ENVIRONMENT_META = {
dev: {},
test: {},
staging: {},
prod: {},
};
type Environments = keyof typeof ENVIRONMENT_META;
// ^? 'dev' | 'test' | 'staging' | 'prod'
数组形式:
const ENVIRONMENTS = ['dev', 'test', 'staging', 'prod'] as const;
type Environments = (typeof ENVIRONMENTS)[number];
// ^? 'dev' | 'test' | 'staging' | 'prod'
需要使用const声明,以确保该数组始终为字面量元组而非string[]。
优先使用type而非interface
对于大多数应用代码而言,type能够涵盖对象结构以及并集、交集、映射类型和条件类型。此外它也无法被重新打开从而导致意外合并:
// when required
interface Person {
name: string;
email: string;
}
// preferred
type Person = {
name: string;
email: string;
};
采用统一的形式可避免在相互重叠的功能之间进行不必要的切换。
仅当其特性真正重要时才使用interface
公共库可能会刻意导出interface,以便使用者对其进行扩展:
// library
export interface Theme {
colors: Record<string, string>;
}
// consumer: extend the interface without modifying the original source
declare module 'my-library' {
interface Theme {
spacing: Record<string, number>;
}
}
为组件属性添加文档说明
在公共属性或那些不易理解的属性——包括其用途、行为及约束条件——上,应在上下文尚清晰时使用 JSDoc(/** ... */)进行标注:
type SearchFieldProps = {
/**
* A unique identifier for the field. Consider [useId](https://react.dev/reference/react/useId) to generate a unique ID.
*/
id?: string;
/**
* The label of the field. For "visually hidden" labels, use the `aria-label`
* attribute.
*/
label?: string;
/**
* @deprecated Use `label` instead.
*/
title?: string;
/**
* Handler that is called when the field is submitted.
*/
onSubmit?: (value: string) => void;
/**
* Handler that is called when the clear button, or <Escape> key, is pressed.
*/
onClear?: () => void;
} & AriaLabellingProps;
文档编写的例外情况
以下情况下可省略冗长的文档:
- 该组件仅被使用一次且不太可能再次出现
- 它是已有文档说明其行为的父组件的私有子组件
不确定时优先保证清晰性,并保持一致性。
重用现有类型
让公共 API 更加明确;将内部实现交由推理机制处理。在创建新的抽象概念之前,先寻找可重用的现有类型、属性或工具函数,以便相关代码保持一致。
访问属性
通过索引方式访问时,会从现有的别名中获取属性类型:
import type { ThingProps } from '../thing';
type SpecialThingProps = {
...
label?: ThingProps['label'];
type?: ThingProps['type'];
};
function SpecialThing(props: SpecialThingProps) {}
组合类型时优先使用交集
在合并类型时避免重复字段(及其文档):
type Person = { name: string; email: string };
type User = Person & { id: string };
// ^? { name: string; email: string; id: string }
import type { ThingProps } from '../thing';
type SpecialThingProps = {
...
} & Pick<ThingProps, 'label' | 'type'>;
function SpecialThing(props: SpecialThingProps) {}
推断实现细节
推理功能会保持内部类型与值的同步:
function initContext() {
return {
name: 'Jane Doe',
email: 'jane.doe@example.com',
};
}
type Context = ReturnType<typeof initContext>;
// ^? { name: string; email: string }
ComponentProps会在库未导出属性时从React组件中提取这些属性:
import type { ComponentProps } from 'react';
import { RouterProvider } from 'react-aria-components';
type RouterProviderProps = ComponentProps<typeof RouterProvider>;
// {
// navigate: (path: Href, routerOptions: RouterOptions | undefined) => void;
// useHref?: (href: Href) => string;
// children: ReactNode;
// }
Parameters会生成一个可按索引访问的参数元组:
// library code
declare function someLibFn(
value: number,
options: {
minimumFractionDigits: number;
maximumFractionDigits: number;
}
): void;
// consumer code
import { someLibFn } from 'some-lib';
type LibFnValue = Parameters<typeof someLibFn>[0];
// ^? number
type LibFnOptions = Parameters<typeof someLibFn>[1];
// ^? { minimumFractionDigits: number, maximumFractionDigits: number }
使用内置工具类型
工具类型涵盖了常见的转换操作,便于新成员理解:
type User = {
id: string;
name: string;
email: string;
};
type UserPatch = Partial<User>;
type UserDetails = Omit<User, 'id'>;
type UserIdentity = Pick<User, 'id' | 'email'>;
只有当转换操作对应真实的领域概念时才应编写自定义工具类型,而非仅仅为了节省输入时间。
使用区分联合类型
带标签的联合类型可用于建模不同选项,从而实现完整性检查:
type Actions =
| { type: 'login'; username: string }
| { type: 'logout'; reason: 'session-timeout' | undefined }
| { type: 'update'; id: string; data: Record<string, unknown> }
共享的字面量字段(type或kind)能让TypeScript安全地缩小每个分支的范围。
总结
一致性比任何单一规则都重要。应就规范达成共识,记录例外情况,并为后续读者优化设计:
- 明确的边界约定
- 可推断的内部结构
- 最少的抑制处理
- 共享的别名与工具函数
- 通过标签标识非法组合并使其消失的联合类型
将这些习惯综合运用,就能让 TypeScript 成为设计辅助工具而非负担:公共 API 保持简洁清晰,内部实现保持 DRY 原则,而剩余的 any 或 as 类型转换也会变得极为罕见,成为需要审核的例外情况而非默认的解决方案。
相关标准表述可见官方 TypeScript 手册和 JSDoc 文档,供希望参考规范用词的读者查阅。
应将此清单视为动态的团队协议,而非一成不变的教条。 同意。