首页 / 文章 / 让代码既严格又灵活的 TypeScript 编码规范

让代码既严格又灵活的 TypeScript 编码规范

实用的 TypeScript 观点:严格模式、unknown over any、稀疏断言、字符串联合类型、类型与接口的区别、JSDoc 属性说明、实用工具函数以及区分联合类型。

1535 词

优秀的 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 文档,供希望参考规范用词的读者查阅。

应将此清单视为动态的团队协议,而非一成不变的教条。 同意。