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 --noEmit。skipLibCheck 可缩短依赖声明检查,但也会隐藏第三方声明冲突;是否启用要基于项目规模和风险决定。
资深面试追问
any、unknown、never 有什么区别?
any 选择退出检查,并污染下游;unknown 表示值未知,使用前必须收窄;never 表示不可能出现的值,可用于永不返回函数和穷尽检查。unknown 是安全的输入顶部类型,never 是没有成员的底部类型。(深入阅读:数据类型详解)
interface 和 type 如何选择?
都能描述对象并支持交叉扩展。interface 支持声明合并,适合希望被扩展的公开对象契约;type 能表达联合、元组、条件和映射。团队应基于是否需要开放扩展和具体表达能力选择,不应把性能或“高级程度”当作绝对结论。(深入阅读:接口 vs 类型别名)
为什么枚举在前端库中需要谨慎?
普通 enum 通常产生运行时代码,并可能在跨包版本不一致时形成兼容问题;const enum 依赖编译配置和跨包内联行为。只需要一组字符串值时,as const 对象或数组加联合类型更透明,也便于与 JSON 和运行时校验协作。确实需要双向映射或独立运行时命名空间时,枚举仍有价值。(深入阅读:枚举 vs 对象字面量)
如何设计一个类型安全的状态机?
用可辨识联合表示合法状态,每个状态只携带当时有效的数据;事件也建模为联合;转换函数根据当前状态与事件返回下一状态,并用 never 做穷尽检查。类型系统负责排除非法组合,运行时仍要处理来自服务端或持久化数据的未知状态。(深入阅读:联合类型与收窄)
实践检查
- 外部数据先用
unknown接收并执行运行时校验。 - 用联合类型表达状态,不用多个布尔值拼装状态机。
- 公共函数的输入、输出和错误语义清晰。
- 泛型确实表达了类型关系,而不是单纯替代具体类型。
tsconfig开启严格模式,类型断言保持在可审查的边界内。- 公共契约从真实运行时行为出发,没有承诺无法保证的类型。
- 条件类型和映射类型减少了事实来源,而没有制造不可读的谜题。
- 外部数据经过运行时校验与领域解析后才进入可信模型。