首页 / 文章

将运行时错误转化为编译错误的十种 TypeScript 模式

学习十种 TypeScript 技术,包括区分联合类型、satisfies 运算、带标记的类型以及 infer 函数,这些技术能让编译器在代码发布前就拒绝无效状态。

发布者:REACTAPP.TOP阅读约 12 分钟2744 词

来源: Top 10 TypeScript Techniques Senior Developers Secretly Use

许多团队通过为现有的 JavaScript 代码添加注解来采用 TypeScript,但仅此而已。这种方法虽能捕捉拼写错误,却让大部分类型系统功能未被利用。更有效的做法是将规则编码为类型,这样整类错误都会导致编译失败,且无需运行时检查。以下是十种实现该方法的技术,并附有说明每种技术何时最有效的提示。

使用区分联合类型明确建模状态

一种常见的做法是将值描述为一组可选字段,然后依赖使用者去检查正确的组合。但这样的方式无法防止同时设置 data 和 error 的情况。

// nothing stops you from having both data and error at once
type ApiResponse = {
  data?: User
  error?: string
  loading?: boolean
}

// consumer has no idea what to check
function handleResponse(res: ApiResponse) {
  if (res.data) { /* maybe fine */ }
  // what if data AND error are both set?
}

区分联合类型会列出所有合法的形态。每种变体都共享一个字面量字段(status),TypeScript会据此进行限定:在success分支中必定存在res.data,而在error分支中则必定存在res.message。

type ApiResponse =
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; message: string }

function handleResponse(res: ApiResponse) {
  switch (res.status) {
    case 'loading':
      return showSpinner()
    case 'success':
      // TypeScript KNOWS res.data exists here
      return renderUser(res.data)
    case 'error':
      // TypeScript KNOWS res.message exists here
      return showError(res.message)
  }
}

如果后来又添加了第四种状态,任何忽略该状态的switch语句都可能引发编译错误,尤其是结合后面提到的穷尽性检查时。

使用satisfies验证对象而不进行类型扩展

自 TypeScript 4.9 起,satisfies 解决了注解中一个细微的问题。在 const config: Config = ... 中,虽然会对对象进行类型检查,但变量的类型会变为确切的 Config,因此 config1.theme 的类型是完整的联合类型而非 'dark'。使用 satisfies 后,虽然仍会对对象进行检查(键中的拼写错误会导致错误),但能保留精确的推断类型:config2.theme 仍为 'dark',且 secondary 依然被视为有效的键。

type Config = {
  theme: 'light' | 'dark' | 'system'
  colors: Record<string, string>
}
// with a normal annotation you lose the literal type
const config1: Config = {
  theme: 'dark',
  colors: { primary: '#3d6fe8' }
}
// config1.theme is now 'light' | 'dark' | 'system', not 'dark'

// with satisfies, the shape is validated but types stay narrow
const config2 = {
  theme: 'dark',
  colors: { primary: '#3d6fe8', secondary: '#7c3aed' }
} satisfies Config// config2.theme is still 'dark' — the literal type is preserved
// config2.colors.primary is still 'string' — shape is validated
// if you typo a field name, TypeScript still catches it

当需要更宽泛的类型时,例如对于将要重新赋值的值,可使用注解;当需要同时进行类型检查并获得精确的推断结果时,则应使用 satisfies。

利用模板字面量类型生成字符串联合类型

TypeScript 可以使用反引号语法来定义字符串类型。占位符中的联合类型会展开为所有可能的组合,这非常适合用于事件处理程序名称、CSS 自定义属性以及 METHOD /path 接口。

type EventName<T extends string> = `on${Capitalize<T>}`
// produces: 'onClick' | 'onChange' | 'onSubmit'

type DomEvents = EventName<'click' | 'change' | 'submit'>
// type safe CSS properties

type CSSVar<T extends string> = `--${T}`
type AppVars = CSSVar<'primary' | 'secondary' | 'bg'>
// produces: '--primary' | '--secondary' | '--bg'
// type safe API routes

type HttpMethod = 'GET' | 'POST' | 'DELETE'
type Route = '/users' | '/orders' | '/products'
type ApiEndpoint = `${HttpMethod} ${Route}`
// produces: 'GET /users' | 'GET /orders' | 'POST /users' | ...

function callApi(endpoint: ApiEndpoint) { /* ... */ }
callApi('GET /users')     // valid
callApi('PATCH /users')   // TypeScript error: not in the type

callApi('PATCH /users') 会被拒绝,因为 PATCH 不在 HttpMethod 中。组合方式会迅速增多,因此应保持输入的联合类型规模较小,以避免拖慢编译器速度。

使用专用类型区分相似值

TypeScript 是通过结构来比较类型的,所以 type UserId = string 实际上就等同于 string,用户 ID 可以顺利传递给期望接收订单 ID 的函数。而专用类型则通过添加在运行时根本不存在的虚拟属性,使原始类型与目标类型相互不兼容。此时构造函数就成了创建这类值的唯一方式,同时也是进行验证的理想位置。

// without branding — TypeScript cannot tell these apart
type UserId = string
type OrderId = string

function deleteOrder(id: OrderId) { /* ... */ }
const userId: UserId = 'user-42'
deleteOrder(userId) // no error - catastrophic bug

// with branded types - structurally different, enforced
type Brand<T, B> = T & { readonly _brand: B }
type UserId  = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>
// constructor functions act as the only way to create these types
const UserId  = (id: string) => id as UserId
const OrderId = (id: string) => id as OrderId
const userId  = UserId('user-42')
const orderId = OrderId('order-99')
deleteOrder(userId)  // TypeScript error: UserId is not assignable to OrderId
deleteOrder(orderId) // works fine

合适的候选项包括标识符(如UserId、OrderId、ProductId)、经过验证的值(如EmailAddress、HashedPassword或ValidatedInput),以及容易混淆的单位(如毫秒与秒、美元与美分)。示例代码中为便于比较将每个别名声明了两次;实际编码时应仅保留正式名称版本。

使用infer从其他类型中提取类型

在条件类型中,infer用于声明一个槽位,TypeScript会通过模式匹配来填充该槽位。这就是ReturnType、Parameters和Awaited的生成方式,同时它也允许你编写自己的提取器。

// extract the return type of any function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never

function getUser() { return { id: 1, name: 'Alice' } }
type User = ReturnType<typeof getUser>
// User = { id: number, name: string }
// extract the resolved value of a Promise
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T
type Data = UnwrapPromise<Promise<User>>
// Data = User
// extract the element type of an array
type ArrayElement<T> = T extends (infer E)[] ? E : never
type Names = ArrayElement<string[]>
// Names = string
// extract first argument of any function
type FirstArg<T> = T extends (first: infer A, ...rest: any[]) => any ? A : never
type Arg = FirstArg<typeof getUser>
// Arg = never (no args), try a fn with args:
type A2 = FirstArg<(id: string) => void>
// A2 = string

将每个定义理解为“如果 T 符合这种结构,那么 infer 位置应填入什么?”。如果模式不匹配,错误分支就会生效,这就是为什么零参数函数的 FirstArg 总是 never 的原因。请为自定义辅助函数起独特的名称,避免覆盖全局的 ReturnType。

让不完整的 switch 语句返回 never

将联合类型与仅接受 never 值的辅助函数配对使用。一旦每个分支都有对应的 case,default 中剩余的类型就会是 never,这样代码就能正常编译。如果尝试添加 hexagon 且不对其进行处理,剩余的类型就会导致编译失败。

type Shape =
  | { kind: 'circle';    radius: number }
  | { kind: 'rectangle'; width: number; height: number }
  | { kind: 'triangle';  base: number;  height: number }

// this helper makes the default case scream at compile time
function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`)
}
function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2
    case 'rectangle':
      return shape.width * shape.height
    case 'triangle':
      return 0.5 * shape.base * shape.height
    default:
      return assertNever(shape)
      // if you add 'hexagon' to Shape and forget to handle it,
      // TypeScript throws an error RIGHT HERE at compile time
  }
}

由于该辅助函数也会抛出异常,因此它可以防止来自类型系统之外的意外值(如 API 返回值)影响程序运行。

利用映射类型和键重映射重塑类型

映射类型会遍历某个类型的键以生成新类型,而as子句可以通过将键映射为never来重命名键或删除它们。这些示例展示了如何构建递归的偏函数、getter签名以及仅保留方法的过滤器。

// make all properties optional but keep their types
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}

// prefix all keys with 'get'
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
type User = { id: string; name: string; email: string }
type UserGetters = Getters<User>
// { getId: () => string, getName: () => string, getEmail: () => string }
// pick only function properties from a type
type FunctionKeys<T> = {
  [K in keyof T as T[K] extends Function ? K : never]: T[K]
}
type Service = { id: string; save(): void; delete(): void; name: string }
type ServiceMethods = FunctionKeys<Service>
// { save: () => void, delete: () => void }

需要使用string & K,因为键也可以是数字或符号,而Capitalize函数无法处理这些类型。这个简单的DeepPartial功能还能递归处理函数和数组,对于普通数据来说已经足够,但可能还需要进一步优化。

利用类型守卫向编译器传授自定义检查规则

TypeScript会在typeof、instanceof以及字面量比较时进行类型收窄,但在自定义检查中则不会。类型谓词(如animal is Cat)用于说明布尔函数所要验证的内容;断言函数(如asserts val is NonNullable)在验证失败时会抛出异常,并使调用之后的所有类型都发生收窄。

type Cat = { kind: 'cat'; meows: boolean }
type Dog = { kind: 'dog'; barks: boolean }
type Animal = Cat | Dog

// type predicate - return type tells TypeScript what the guard proves
function isCat(animal: Animal): animal is Cat {
  return animal.kind === 'cat'
}
function speak(animal: Animal) {
  if (isCat(animal)) {
    console.log(animal.meows) // TypeScript knows animal is Cat here
  } else {
    console.log(animal.barks) // TypeScript knows animal is Dog here
  }
}
// assertion function - throws if condition not met, narrows after the call
function assertDefined<T>(val: T): asserts val is NonNullable<T> {
  if (val === null || val === undefined) {
    throw new Error('Value is null or undefined')
  }
}
const user = getUser() // returns User | null
assertDefined(user)    // throws at runtime if null
console.log(user.name) // TypeScript knows user is User here, not User | null

编译器会直接信任这些签名而不会进行验证,因此错误的保护逻辑会误导所有调用者。应保持这些逻辑简洁并经过充分测试。

使用as const确保字面量值的精确性

TypeScript 默认会扩展字面量的类型:'production' 会被视为 string 类型。as const 则会将表达式锁定在其最窄的类型上,并使其变为 readonly。真正的优势在于可以通过 typeof 和索引访问从数据中推导出联合类型,从而使数组或对象成为唯一的真实数据来源。

// without as const — types are widened
const config = {
  env: 'production',     // inferred as string, not 'production'
  port: 3000,             // inferred as number, not 3000
  features: ['auth', 'search'] // inferred as string[]
}

// with as const - every value is exactly as written
const config = {
  env: 'production',
  port: 3000,
  features: ['auth', 'search']
} as const
// config.env is 'production', not string
// config.port is 3000, not number
// config.features is readonly ['auth', 'search']
// the real power: derive union types from the values
const ROLES = ['admin', 'editor', 'viewer'] as const
type Role = typeof ROLES[number]
// Role = 'admin' | 'editor' | 'viewer'
// add a new role to the array and the type updates automatically
const ROUTES = {
  home:    '/',
  users:   '/users',
  profile: '/profile'
} as const
type AppRoute = typeof ROUTES[keyof typeof ROUTES]
// AppRoute = '/' | '/users' | '/profile'

该代码片段为了对比效果,两次声明了 config。采用这种模式,添加新的角色或路由时无需重复操作即可更新所有相关类型。

利用条件类型实现返回值和输入类型的多样化

条件类型允许泛型函数根据其参数返回不同的结构,Prisma正是通过这种方式为include和select操作的结果定义类型。此处当includePosts: true时,返回的类型会包含posts字段;而在第二个示例中,publishedAt仅在publish模式下为必填字段。

// function returns different shapes based on a flag
type UserResponse<T extends boolean> =
  T extends true
    ? { user: User; posts: Post[] }   // with posts
    : { user: User }                   // without posts

function fetchUser<T extends boolean>(
  id: string,
  includePosts: T
): Promise<UserResponse<T>> {
  /* implementation */
}
// TypeScript knows exactly what comes back
const result1 = await fetchUser('123', true)
result1.posts   // valid - TypeScript knows posts exists
const result2 = await fetchUser('123', false)
result2.posts   // TypeScript error - posts does not exist on this return type
// another real world example: optional fields based on mode
type CreateMode = 'draft' | 'publish'
type ArticleInput<M extends CreateMode> =
  M extends 'publish'
    ? { title: string; content: string; publishedAt: Date }
    : { title: string; content?: string }
function createArticle<M extends CreateMode>(
  mode: M,
  input: ArticleInput<M>
) { /* ... */ }
// publishedAt is REQUIRED when mode is 'publish'
createArticle('publish', { title: 'Hey', content: '...', publishedAt: new Date() })
// publishedAt is NOT required when mode is 'draft'
createArticle('draft',   { title: 'Hey' })

在实现代码中,TypeScript通常无法证明返回值与某个未确定的条件类型匹配,因此需要使用类型转换。对于两三种不同的情况,重载写法往往更为清晰。

各部分如何协同工作

这些并非孤立的技巧。联合类型定义了状态,never确保了对所有情况的处理,as const与模板字面量用于生成联合类型,而映射类型和条件类型则用于推导其余部分。类型由此成为逻辑层而非文档。以下的简写可作为检查清单使用;它假定存在如Config、User和Animal这样的类型。

// 01. Discriminated unions — model every valid state explicitly
type State = | { status: 'idle' } | { status: 'ok'; data: T } | { status: 'err'; msg: string }
// 02. satisfies - validate shape without widening literal types
const x = { mode: 'dark' } satisfies Config
// 03. Template literal types - build string unions programmatically
type Event<T extends string> = `on${Capitalize<T>}`
// 04. Branded types - prevent mixing structurally identical primitives
type UserId = string & { readonly _brand: 'UserId' }
// 05. infer - extract types from inside other types
type Unwrap<T> = T extends Promise<infer V> ? V : T
// 06. assertNever - exhaustiveness check at compile time
function assertNever(x: never): never { throw new Error('Unhandled case') }
// 07. Mapped types - transform type shapes programmatically
type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] }
// 08. Type guards - teach TypeScript how to narrow your types
function isCat(a: Animal): a is Cat { return a.kind === 'cat' }
// 09. as const - lock values to literals, derive union types from data
const ROLES = ['admin', 'editor'] as const
type Role = typeof ROLES[number]
// 10. Conditional types - return different shapes based on input
type Res<T extends boolean> = T extends true ? { data: User; posts: Post[] } : { data: User }

关键要点

  • 优先选择结构明确的类型而非包含可选字段的类型;通过联合类型与assertNever可将缺失情况转化为构建失败。
  • 使用satisfies和as const在保留字面量类型的同时验证值。
  • 为有意义的原始类型添加标识,以避免ID和单位被混淆。
  • 利用infer、映射类型和条件类型来计算类型,但需确保其可读性。
  • 将自定义守卫和类型转换视为信任边界。
  • 如需更全面地了解业务规则的编码方式,请参阅TypeScript 中的领域建模。