TypeScript
类型基础
interface vs type(最高频):
interface | type | |
|---|---|---|
| 声明合并 | ✅ 同名自动合并 | ❌ 重复声明报错 |
| 继承 | extends | 交叉类型 & |
| 描述对象 | ✅ | ✅ |
| 联合 / 元组 / 原始类型别名 | ❌ | ✅ |
| 映射类型、条件类型 | ❌ | ✅ |
| 性能 | 缓存更好(大型项目有感) | 复杂类型可能更慢 |
选型:描述对象形状和公共 API 用 interface(可被使用方扩展);需要联合、元组、工具类型、条件类型用 type。团队内统一比争论哪个好更重要。
声明合并的实用场景 —— 给第三方库或全局对象扩展类型:
declare global {
interface Window { __MY_APP__: AppState }
}
any vs unknown vs never:
any—— 关闭类型检查,会传染(任何操作都返回 any)。应该当成「技术债标记」而非解决方案unknown—— 类型安全的 any。必须先收窄类型才能使用,是接收外部数据(API 响应、JSON.parse)的正确类型never—— 不可能存在的值。用于穷尽检查和标记永不返回的函数
穷尽检查(exhaustiveness check) 是 never 最实用的场景:
type Shape = { kind: 'circle', r: number } | { kind: 'square', s: number }
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.r ** 2
case 'square': return shape.s ** 2
default:
const _exhaustive: never = shape // 新增 kind 但没处理时这行会编译报错
throw new Error('unreachable')
}
}
价值:以后给 Shape 加一个成员,所有没处理新情况的 switch 都会在编译时报错。这是 TS 相对 JS 最实际的收益之一。
类型收窄
// typeof —— 原始类型
if (typeof x === 'string') { /* x: string */ }
// instanceof —— 类
if (e instanceof Error) { /* e: Error */ }
// in —— 属性存在
if ('name' in obj) { /* obj 有 name */ }
// 可辨识联合(最常用、最可靠)
if (shape.kind === 'circle') { /* 自动收窄到 circle 分支 */ }
// 类型谓词:自定义收窄
function isString(x: unknown): x is string {
return typeof x === 'string'
}
// 断言函数
function assertIsUser(x: unknown): asserts x is User {
if (!x || typeof x !== 'object') throw new Error()
}
可辨识联合(Discriminated Union)是 TS 建模的核心模式 —— 用一个字面量字段(kind/type/status)区分联合成员,TS 就能自动收窄。典型应用是请求状态:
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success', data: User } // 只有 success 才有 data
| { status: 'error', error: string }
好处:不可能出现「status 是 loading 但有 data」这种非法状态 —— 用类型消除了不可能的状态组合,比 { loading: boolean, data?: User, error?: string } 健壮得多。
泛型
// 基础
function first<T>(arr: T[]): T | undefined { return arr[0] }
// 约束
function getLen<T extends { length: number }>(x: T): number { return x.length }
// 默认值
interface Box<T = string> { value: T }
// 多参数 + keyof 约束(最实用的模式之一)
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
return keys.reduce((acc, k) => ({...acc, [k]: obj[k]}), {} as Pick<T, K>)
}
keyof + 泛型约束保证了「只能取对象上真实存在的键」,且返回类型精确。这是类型安全的工具函数的基本写法。
工具类型
内置的(必须会):
| 类型 | 作用 |
|---|---|
Partial<T> | 所有属性变可选 |
Required<T> | 所有属性变必需 |
Readonly<T> | 所有属性变只读 |
Pick<T, K> | 挑选指定属性 |
Omit<T, K> | 排除指定属性 |
Record<K, V> | 构造键值类型 |
Exclude<T, U> | 从联合类型中排除 |
Extract<T, U> | 从联合类型中提取 |
NonNullable<T> | 排除 null / undefined |
ReturnType<F> | 取函数返回值类型 |
Parameters<F> | 取函数参数类型(元组) |
Awaited<T> | 取 Promise 解析后的类型 |
手写实现(高频考察):
type MyPartial<T> = { [K in keyof T]?: T[K] }
type MyRequired<T> = { [K in keyof T]-?: T[K] } // -? 移除可选
type MyReadonly<T> = { readonly [K in keyof T]: T[K] }
type MyPick<T, K extends keyof T> = { [P in K]: T[P] }
type MyExclude<T, U> = T extends U ? never : T // 分布式条件类型
type MyOmit<T, K extends keyof any> = MyPick<T, MyExclude<keyof T, K>>
type MyReturnType<F> = F extends (...args: any[]) => infer R ? R : never
type MyNonNullable<T> = T extends null | undefined ? never : T
两个关键机制:
1. 分布式条件类型 —— 当条件类型作用于裸的类型参数且传入联合类型时,会分别对每个成员求值再联合:
type T = MyExclude<'a' | 'b' | 'c', 'a'>
// 展开成:('a' extends 'a' ? never : 'a') | ('b' extends 'a' ? never : 'b') | ...
// = never | 'b' | 'c' = 'b' | 'c'
用 [T] extends [U] 可以关闭分布式行为(包装成元组就不是裸类型参数了)。
2. infer —— 在条件类型里声明一个待推断的类型变量:
type Unwrap<T> = T extends Promise<infer U> ? U : T
type First<T> = T extends [infer F, ...any[]] ? F : never
type Last<T> = T extends [...any[], infer L] ? L : never
类型体操常考题
// 深度只读
type DeepReadonly<T> = T extends Function ? T
: T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T
// 深度可选
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> } : T
// 联合转交叉(经典:利用函数参数的逆变性)
type UnionToIntersection<U> =
(U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never
// 可选属性的键
type OptionalKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? K : never
}[keyof T]
// 元组转联合 / 长度
type TupleToUnion<T extends readonly any[]> = T[number]
type Length<T extends readonly any[]> = T['length']
// 字符串操作
type Capitalize2<S extends string> = S extends `${infer F}${infer R}`
? `${Uppercase<F>}${R}` : S
// 驼峰转下划线
type SnakeCase<S extends string> = S extends `${infer H}${infer T}`
? T extends Uncapitalize<T>
? `${Lowercase<H>}${SnakeCase<T>}`
: `${Lowercase<H>}_${SnakeCase<T>}`
: S
:::tip 类型体操的实际价值 面试可能考,但日常开发不该写这种代码 —— 可读性差、编译慢、维护成本高。
真正有价值的是:用类型准确建模业务领域(可辨识联合消除非法状态)、端到端类型安全(tRPC、Zod 从 schema 推导类型、GraphQL Codegen)、穷尽检查。
面试被问到类型体操,答完实现后可以补一句「实际项目里我更倾向用简单的类型 + Zod 做运行时校验,复杂的类型推导交给工具生成」—— 这显示的是工程判断而不只是技巧。 :::
协变与逆变
协变(covariant) —— 子类型可以赋值给父类型的位置。对象属性和数组是协变的:
let animals: Animal[] = dogs // ✅ Dog[] 赋值给 Animal[]
逆变(contravariant) —— 函数参数是逆变的:
type Handler<T> = (x: T) => void
let h: Handler<Dog> = (a: Animal) => {} // ✅ 接受 Animal 的函数可以处理 Dog
let h2: Handler<Animal> = (d: Dog) => {} // ❌ 不安全(strictFunctionTypes 下报错)
直觉解释:一个能处理「所有动物」的函数,当然也能处理「狗」;但一个只能处理「狗」的函数,不能放在「要处理所有动物」的位置上。
注意方法形式的参数是双变的(为了兼容性,TS 刻意保留的不安全行为),strictFunctionTypes 只对函数类型字面量生效。
实用配置与模式
tsconfig.json 的关键项:
{
"strict": true, // 必开,包含下面这些
"noUncheckedIndexedAccess": true, // arr[0] 的类型是 T | undefined(很有用但很多项目没开)
"exactOptionalPropertyTypes": true, // 区分「不存在」和「值为 undefined」
"noImplicitOverride": true,
"verbatimModuleSyntax": true // 明确区分 import type
}
import type —— 明确标记只导入类型,构建时会被完全擦除,避免意外保留运行时导入(可能引入副作用或循环依赖)。
声明文件(.d.ts) —— 给没有类型的 JS 库补类型。declare module 'xxx' 兜底、declare global 扩展全局。
satisfies(4.9+) —— 校验但不扩宽类型:
// as const 太死,类型注解会丢失字面量信息
const config = { port: 3000, host: 'localhost' } satisfies Config
config.port // 类型是 3000 而不是 number,同时仍校验符合 Config
as 断言要谨慎 —— 它是「我比编译器更清楚」的声明,用错了就是运行时错误。宁可用类型谓词或 Zod 做运行时校验。as unknown as T 的双重断言更是明确的危险信号。
怎么答 TS 的价值
被问「TypeScript 有什么好处」,别只说「有类型提示」。更有说服力的是:
- 重构信心 —— 改一个接口,所有受影响的地方编译时就报错,不用靠跑测试或上线后发现
- 用类型消除非法状态 —— 可辨识联合让「loading 且有 data」这种状态不可能存在
- 穷尽检查 —— 加枚举值后,所有没处理的分支编译报错
- 自文档化 —— 函数签名就是文档,且永远不会和实现不同步
- IDE 能力 —— 精确的跳转、重命名、自动补全
代价也要能说:编译步骤、学习曲线、类型定义的维护成本、复杂类型拖慢 IDE、第三方库类型质量不一。TS 只保证编译时安全,运行时的外部数据仍需校验(所以要配 Zod / io-ts)—— 这是最容易被忽略的一点。