跳到正文
前端知识库
TypeScript

TypeScript 类型系统与资深面试

从结构化类型、收窄和泛型深入到条件类型、型变、运行时边界与公共类型设计。

8 分钟TypeScript · 类型系统 · 工程化 · 面试

TypeScript 的价值不是给每个变量补注解,而是把业务约束编码进类型,让错误尽可能在运行前暴露。

类型推断优先

能从赋值中推断的类型通常不需要重复声明:

const projectName = 'knowledge-base' // string
const retryCount = 3                 // number
const enabled = true                 // boolean

const tags = ['TypeScript', 'React'] // string[]

函数参数是系统无法自行推断的输入边界,应明确标注;返回值可在简单函数中交给编译器推断。

function formatTitle(title: string, category?: string) {
  return category ? `${category} / ${title}` : title
}

对象类型

interface 适合描述可扩展的对象契约,type 适合组合联合类型、映射类型和别名。两者没有绝对优劣。

interface Article {
  id: string
  title: string
  description?: string
  tags: string[]
}

type ArticlePreview = Pick<Article, 'id' | 'title' | 'description'>

可选属性 description?: string 表示属性可能不存在。使用前要先收窄,而不是用非空断言掩盖问题。

联合类型与收窄

联合类型可以准确表达“多个合法状态中的一个”:

type RequestState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: Article[] }
  | { status: 'error'; message: string }

function getResultCount(state: RequestState) {
  if (state.status !== 'success') return 0
  return state.data.length
}

这种可辨识联合比多个互不关联的布尔值更安全,因为它无法表示“加载中但同时成功”之类的非法组合。

常见收窄方式包括:

  • typeof value === 'string'
  • value instanceof Date
  • 'property' in value
  • 判别字段,例如 state.status === 'success'
  • 自定义类型守卫
function isArticle(value: unknown): value is Article {
  if (typeof value !== 'object' || value === null) return false
  return 'id' in value && 'title' in value && 'tags' in value
}

unknown 优于 any

any 会关闭类型检查并向下游传播。外部输入应先视为 unknown,完成校验后再使用。

async function loadSettings(): Promise<unknown> {
  const response = await fetch('/settings.json')
  return response.json()
}

const settings = await loadSettings()

if (typeof settings === 'object' && settings !== null && 'theme' in settings) {
  console.log(settings.theme)
}

真实项目可使用 schema 校验库同时完成运行时校验和类型推导。

泛型表达关系

泛型的重点是表达多个位置之间的类型关系,而不是把类型写得更复杂。

function first<T>(items: readonly T[]): T | undefined {
  return items[0]
}

function getProperty<T, K extends keyof T>(object: T, key: K): T[K] {
  return object[key]
}

const article: Article = {
  id: 'ts-basics',
  title: 'TypeScript 基础',
  tags: ['TypeScript'],
}

const title = getProperty(article, 'title') // string

readonly T[] 表示函数只读取数组,调用者可以安全传入普通数组或只读数组。

常用工具类型

  • Partial<T>:所有属性变为可选,适合更新补丁。
  • Required<T>:所有属性变为必填。
  • Pick<T, K>:选择部分属性。
  • Omit<T, K>:排除部分属性。
  • Record<K, V>:键到值的映射。
  • ReturnType<T>:提取函数返回类型。
type ArticlePatch = Partial<Pick<Article, 'title' | 'description' | 'tags'>>
type ArticlesById = Record<string, Article>

函数与错误处理

不要把“没有结果”和“执行失败”混为一谈:

function findArticle(id: string, articles: Article[]): Article | undefined {
  return articles.find((article) => article.id === id)
}

function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${String(value)}`)
}

never 可用于联合类型的穷尽检查。当新增状态而分支未处理时,编译器会立刻报错。

推荐的严格配置

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true
  }
}

逐步接入老项目时,可以先开启 strict,再按风险逐项加强。不要用大量 as! 让报错消失,它们只是把验证责任从编译器转移给开发者。

结构化类型与可靠性边界

TypeScript 主要采用结构化类型:只要值具有目标类型要求的成员,就可以赋值,而不要求显式声明“实现了某类型”。

interface Named {
  name: string
}

const account = { id: 'u-1', name: 'Ada' }
const named: Named = account // 合法,结构满足即可

对象字面量会执行额外属性检查,已有变量则按普通结构兼容判断:

function printName(value: Named) {}

printName({ name: 'Ada', role: 'admin' }) // 报错:字面量包含未知属性

const user = { name: 'Ada', role: 'admin' }
printName(user) // 合法

这不是矛盾,而是 TypeScript 用字面量检查捕获常见拼写错误。类型系统也不是完全可靠(sound)的证明系统:类型断言、可变数组、索引访问、声明文件错误和未校验的外部数据都可能让运行时值偏离静态类型。资深开发需要明确“哪里由编译器保证,哪里仍要运行时验证”。

需要区分相同结构但不同业务语义时,可使用 branded type:

declare const userIdBrand: unique symbol
type UserId = string & { readonly [userIdBrand]: true }

function parseUserId(value: string): UserId {
  if (!/^usr_[a-z0-9]+$/.test(value)) throw new Error('Invalid user id')
  return value as UserId
}

断言被限制在完成校验的构造边界内,业务代码不能把任意字符串直接当作 UserId

satisfies、字面量推断与不可变性

类型注解会把表达式直接约束为目标类型,可能丢失更具体的推断;satisfies 检查兼容性,同时保留表达式自身的精确类型:

type RouteName = 'home' | 'article'
type RouteConfig = { path: string; preload?: boolean }

const routes = {
  home: { path: '/', preload: true },
  article: { path: '/articles/:id' },
} satisfies Record<RouteName, RouteConfig>

routes.home.preload // boolean;键名仍保留为具体联合

as const 会把字面量推断为最窄类型,并递归添加只读修饰;它不是运行时冻结。需要运行时不可变还要使用 Object.freeze 或不可变更新策略。

const statuses = ['idle', 'loading', 'success', 'error'] as const
type Status = (typeof statuses)[number]

这种“从值推导类型”的方式能减少运行时常量与类型联合重复维护。

条件类型、infer 与分配行为

条件类型根据类型关系选择分支:

type AwaitedValue<T> = T extends PromiseLike<infer Value> ? Value : T

type A = AwaitedValue<Promise<string>> // string
type B = AwaitedValue<number>          // number

当裸类型参数位于 extends 左侧时,联合类型会被逐项分配:

type ToArray<T> = T extends unknown ? T[] : never
type Distributed = ToArray<string | number> // string[] | number[]

type ToArrayTogether<T> = [T] extends [unknown] ? T[] : never
type Together = ToArrayTogether<string | number> // (string | number)[]

infer 用于在条件匹配中声明待推断部分,适合提取函数参数、返回值、Promise 内值或组件 props。复杂条件类型应有明确公共价值;如果只有作者能读懂,维护成本可能超过减少的几行重复。

映射类型与键重映射

映射类型遍历属性键,并可增删 readonly、可选修饰或重映射键名:

type EventHandlers<T extends Record<string, unknown>> = {
  [Key in keyof T as `on${Capitalize<string & Key>}Change`]:
    (value: T[Key]) => void
}

type Settings = {
  theme: 'light' | 'dark'
  pageSize: number
}

type SettingsHandlers = EventHandlers<Settings>
// { onThemeChange: ...; onPageSizeChange: ... }

注意 keyof 可能包含 string | number | symbol,模板字面量键通常要先与 string 求交。映射类型适合从一个事实来源推导多个契约,避免手工同步字段。

函数型变与回调安全

型变描述泛型类型之间的兼容方向。直观理解:

  • 只返回 T 的生产者通常协变,可以返回更具体的类型。
  • 只接收 T 的消费者在严格函数类型下通常逆变,能处理更宽输入的函数更安全。
  • 同时读写 T 的可变结构通常应视为不变,否则可能写入错误子类型。
class Animal { name = '' }
class Dog extends Animal { bark() {} }

type Handler<T> = (value: T) => void

const handleAnimal: Handler<Animal> = () => {}
const handleDog: Handler<Dog> = handleAnimal // 安全:它能处理所有 Animal

回调参数越宽,能处理的输入越多。strictFunctionTypes 会加强函数参数检查,但方法参数因兼容历史模式存在例外。面试中不必死背术语,能用“读、写、输入、输出”的方向解释兼容性更重要。

重载、联合与泛型如何选择

如果输入与输出之间存在对应关系,优先用泛型或重载表达;如果所有分支返回相同类型,联合参数通常更简单。

function parse(value: string): string[]
function parse(value: ArrayBuffer): Uint8Array
function parse(value: string | ArrayBuffer) {
  return typeof value === 'string'
    ? value.split(',')
    : new Uint8Array(value)
}

实现签名对调用方不可见,必须兼容所有重载。重载应从具体到宽泛排列。不要写一长串仅参数数量不同的重载;可变参数元组往往更容易维护。

泛型参数若只出现一次,通常没有表达关系,可能直接使用具体类型或 unknown 更合适。默认泛型也不能用来掩盖设计不清晰的 API。

外部数据与运行时校验

类型只存在于编译期。response.json() as User 不会检查响应:

type User = {
  id: string
  displayName: string
}

function parseUser(input: unknown): User {
  if (typeof input !== 'object' || input === null) {
    throw new Error('User must be an object')
  }

  if (!('id' in input) || typeof input.id !== 'string') {
    throw new Error('User id must be a string')
  }

  if (!('displayName' in input) || typeof input.displayName !== 'string') {
    throw new Error('User displayName must be a string')
  }

  return { id: input.id, displayName: input.displayName }
}

在 API、localStorage、URL 参数、消息事件、环境变量和第三方 SDK 等信任边界执行校验。大型 schema 可使用运行时校验库,并从 schema 推导类型,避免静态类型和验证规则各维护一份。

校验之后还可能需要领域解析,例如字符串格式的日期、金额精度、枚举兼容和旧版本迁移。能通过结构校验不代表已经满足业务不变量。

公共 API 与类型发布

库或跨团队模块的类型设计要考虑消费者:

  • 导出最小稳定契约,不泄露内部实现类型。
  • 参数接受较宽的只读输入,返回明确且便于收窄的结果。
  • 破坏性变更不仅包括运行时代码,也包括收窄参数、扩大可能返回值等类型变化。
  • 声明文件必须与真实 JavaScript 行为一致,不能用类型承诺不存在的保证。
  • 避免在公共类型中暴露依赖包的私有深层路径,否则依赖升级会传导给消费者。

tsc 的类型检查与代码转译是两个概念。很多构建器只移除类型,不执行完整类型检查,因此 CI 仍需单独运行 tsc --noEmitskipLibCheck 可缩短依赖声明检查,但也会隐藏第三方声明冲突;是否启用要基于项目规模和风险决定。

资深面试追问

anyunknownnever 有什么区别?

any 选择退出检查,并污染下游;unknown 表示值未知,使用前必须收窄;never 表示不可能出现的值,可用于永不返回函数和穷尽检查。unknown 是安全的输入顶部类型,never 是没有成员的底部类型。(深入阅读:数据类型详解

interfacetype 如何选择?

都能描述对象并支持交叉扩展。interface 支持声明合并,适合希望被扩展的公开对象契约;type 能表达联合、元组、条件和映射。团队应基于是否需要开放扩展和具体表达能力选择,不应把性能或“高级程度”当作绝对结论。(深入阅读:接口 vs 类型别名

为什么枚举在前端库中需要谨慎?

普通 enum 通常产生运行时代码,并可能在跨包版本不一致时形成兼容问题;const enum 依赖编译配置和跨包内联行为。只需要一组字符串值时,as const 对象或数组加联合类型更透明,也便于与 JSON 和运行时校验协作。确实需要双向映射或独立运行时命名空间时,枚举仍有价值。(深入阅读:枚举 vs 对象字面量

如何设计一个类型安全的状态机?

用可辨识联合表示合法状态,每个状态只携带当时有效的数据;事件也建模为联合;转换函数根据当前状态与事件返回下一状态,并用 never 做穷尽检查。类型系统负责排除非法组合,运行时仍要处理来自服务端或持久化数据的未知状态。(深入阅读:联合类型与收窄

实践检查

  • 外部数据先用 unknown 接收并执行运行时校验。
  • 用联合类型表达状态,不用多个布尔值拼装状态机。
  • 公共函数的输入、输出和错误语义清晰。
  • 泛型确实表达了类型关系,而不是单纯替代具体类型。
  • tsconfig 开启严格模式,类型断言保持在可审查的边界内。
  • 公共契约从真实运行时行为出发,没有承诺无法保证的类型。
  • 条件类型和映射类型减少了事实来源,而没有制造不可读的谜题。
  • 外部数据经过运行时校验与领域解析后才进入可信模型。