接口与类型别名
本章涵盖 TypeScript 类型系统的两大核心抽象工具:interface(接口)和 type(类型别名)。两者均能描述对象形状,但在扩展方式、声明合并、适用场景上有本质区别。本章从零讲清接口全部语法,再与类型别名逐项对比,最后给出选型决策表。
interface 接口
interface 是 TypeScript 的核心抽象机制,为对象、函数、类的形状命名。它定义了一份"契约"——实现该接口的值必须满足其结构约束。
🔬 深入原理:TypeScript 采用结构类型(Structural Typing),而非名义类型。只要值的形状满足接口定义,就视为实现了该接口,无需显式
implements声明。这被称为"鸭子类型"(duck typing)。
基本语法
interface User {
name: string
age: number
}
const user: User = { name: "Willow", age: 18 }分号或逗号均可作为分隔符,也可省略(换行分隔)。以下写法等价:
interface User {
name: string
age: number
}
// 也合法,但风格不统一,建议选一种并在项目中保持一致
interface User {
name: string;
age: number;
}可选属性
在属性名后加 ? 表示可选,赋值时可省略该属性:
interface Config {
url: string
method?: string // 可选
timeout?: number // 可选
}
const c1: Config = { url: "/api" } // ✅
const c2: Config = { url: "/api", timeout: 5000 } // ✅
💡 最佳实践:可选属性与索引签名搭配时要小心——当索引签名的值类型与可选属性不一致时可能引发类型冲突。详见"常见陷阱"。
只读属性
readonly 修饰的属性只能在对象初始化时赋值,之后无法修改:
interface Point {
readonly x: number
readonly y: number
}
const p: Point = { x: 10, y: 20 }
// p.x = 30 // ❌ Cannot assign to 'x' because it is a read-only property.
🚨 陷阱:
readonly仅约束第一层赋值,不提供深层不可变性。对于嵌套对象,内层属性仍可修改:interface Config { readonly db: { host: string; port: number } } const c: Config = { db: { host: "localhost", port: 5432 } } c.db.host = "remote" // ✅ 允许!readonly 不递归需要深度只读时,使用
Readonly<T>映射类型或as const断言。
ReadonlyArray<T> 是内置的只读数组类型,可防止 push/splice 等变异操作:
const arr: ReadonlyArray<number> = [1, 2, 3]
// arr.push(4) // ❌ Property 'push' does not exist on type 'readonly number[]'
索引签名
当对象的键名不确定时,使用索引签名描述所有键值对的类型:
// 所有 key 都是 string,所有 value 也是 string
interface StringMap {
[key: string]: string
}
const m: StringMap = {
name: "Willow",
city: "Hangzhou"
}索引签名的 key 类型只能是 string 或 number(或模板字面量类型,TS 4.4+):
// 数字索引 —— 数组的底层定义
interface NumberArray {
[index: number]: string
}
const arr: NumberArray = ["a", "b", "c"]
console.log(arr[0]) // "a"
// 模板字面量索引(TS 4.4+)
interface EventMap {
[key: `on${Capitalize<string>}`]: (value: unknown) => void
}
const handlers: EventMap = {
onClick: (v) => {}, // ✅
onChange: (v) => {}, // ✅
// submit: (v) => {} // ❌ key 必须以 "on" 开头并大写第二个字母
}🚨 陷阱:同时声明字符串索引和明确属性时,所有明确属性的类型必须是索引签名值类型的子类型:
interface Bad { [key: string]: number name: string // ❌ Property 'name' of type 'string' is not assignable to 'string' index type 'number'. }
函数类型接口
interface 可直接描述一个函数的调用签名:
interface Comparator {
(a: number, b: number): number
}
const compare: Comparator = (a, b) => a - b也可以同时描述函数的属性和调用签名,构成混合类型:
interface Counter {
(): void // 可调用
count: number // 有属性
reset(): void // 有方法
}
function createCounter(): Counter {
const counter = (() => { counter.count++ }) as Counter
counter.count = 0
counter.reset = () => { counter.count = 0 }
return counter
}
const c = createCounter()
c() // count = 1
c()
console.log(c.count) // 2
c.reset()
console.log(c.count) // 0
🔬 深入原理:混合类型描述了 JavaScript 中常见的"可调用对象"模式(jQuery 的
$、lodash 的_都是既可作为函数调用又带有方法的对象)。使用interface描述比type更自然,且可利用声明合并追加属性。
接口继承(extends)
extends 关键字让接口继承一个或多个接口的成员,实现类型复用:
interface Animal {
name: string
}
interface Dog extends Animal {
breed: string
}
const dog: Dog = { name: "Buddy", breed: "Golden Retriever" }多重继承——一个接口可以同时继承多个父接口:
interface Animal {
name: string
}
interface Pet {
owner: string
}
interface Cat extends Animal, Pet {
purr(): void
}
const cat: Cat = {
name: "Whiskers",
owner: "Willow",
purr: () => console.log("purr...")
}子接口可以覆写父接口的成员,但必须兼容父类型(覆写后的类型必须是原类型的子类型):
interface Base {
id: string | number
}
interface Derived extends Base {
id: number // ✅ number 是 string | number 的子类型
}声明合并(Declaration Merging)
这是 interface 独有的特性——多个同名 interface 声明会自动合并,不会报错:
interface Window {
title: string
}
interface Window {
version: number
}
// 自动合并为:
// interface Window {
// title: string
// version: number
// }
const win: Window = { title: "My App", version: 1 } // ✅
🔬 深入原理:声明合并在编译期完成,TypeScript 收集所有同名接口的成员,合并为一个接口类型。这一机制使得 TypeScript 可以向已有类型"增量添加"属性,是扩展第三方库类型的核心手段。
type不支持此特性——同名type会直接报错。
典型应用——扩展第三方库的类型定义:
// 扩展 Express 的 Request 对象
declare namespace Express {
interface Request {
user?: { id: string; role: string }
}
}
// 扩展全局类型
interface String {
isEmail(): boolean
}🚨 陷阱:声明合并是全局性的。在
.d.ts文件中修改全局接口会影响整个项目。应谨慎使用,避免污染全局命名空间或在无意中覆盖已有属性。
type 类型别名
type 为任意类型赋予别名——不仅限于对象,还涵盖原始类型、联合类型、元组、函数签名等所有 TypeScript 类型。
基本别名
type Name = string
type Age = number
type Point = [number, number]
const name: Name = "Willow"
const age: Age = 18
const pos: Point = [120, 30]对象类型别名
type User = {
name: string
age: number
address?: string
}
const user: User = { name: "Willow", age: 18 }联合类型(Union)
type 的核心优势——定义一组可选值的联合:
type Status = "pending" | "success" | "error"
type ID = string | number
function handle(s: Status) { /* ... */ }
handle("pending") // ✅
// handle("done") // ❌ Argument of type '"done"' is not assignable
type Result<T> = { ok: true; value: T } | { ok: false; error: string }交叉类型(Intersection)
& 运算符合并多个类型,要求值同时满足所有约束:
type Nameable = { name: string }
type Aged = { age: number }
type Person = Nameable & Aged
const p: Person = { name: "Willow", age: 18 } // ✅
🚨 陷阱:交叉类型合并同名属性时,类型会取交集。若交集为空(如
{ x: string } & { x: number }),属性类型变为never,导致无法赋值:type A = { x: string } type B = { x: number } type C = A & B // C = { x: never } ← string 和 number 无交集
元组与函数别名
type Point2D = [number, number]
type Point3D = [number, number, number]
type Callback = (data: string) => void
type Handler<T> = (event: T) => void
type Constructor<T> = new (...args: any[]) => T配合映射类型和条件类型
type 在此类高级类型中不可或缺(interface 无法直接使用映射类型):
type ReadonlyUser = Readonly<User>
type PartialUser = Partial<User>
type UserKeys = keyof User // "name" | "age" | "address"
type Nullable<T> = { [K in keyof T]: T[K] | null }
type IsString<T> = T extends string ? "yes" : "no"interface vs type 对比
| 特性 | interface | type |
|---|---|---|
| 描述对象形状 | ✅ | ✅ |
| 描述函数类型 | ✅ | ✅ |
联合类型 (|) |
❌ | ✅ |
交叉类型 (&) |
通过 extends 间接实现 |
✅ 直接使用 & |
| 元组类型 | ❌(可用索引签名模拟) | ✅ |
| 原始类型别名 | ❌ | ✅ |
| 声明合并 | ✅(同名自动合并) | ❌(报错 “Duplicate identifier”) |
| 扩展方式 | extends(可继承多个) |
& 交叉运算符 |
| IDE 悬停提示 | 显示接口名称 | 展开所有成员(或显示名称,取决于复杂度) |
extends 从句 |
✅ | ❌(仅可用在泛型约束中) |
implements 从句 |
✅ | ✅ |
| 映射类型 | ❌ | ✅ |
| 条件类型 | ❌ | ✅ |
| 同名冲突 | 自动合并(安全) | 编译错误 |
| 递归引用 | ✅ 支持自引用 | ✅ 支持自引用 |
性能提示
⚡ 性能提示:
interface的 extends 检查采用"预计算+缓存"策略,在大型代码库中编译速度通常优于深层嵌套的&交叉类型。type使用交叉合并大量类型时,编译器需要展开每一个成员,可能导致类型检查变慢。对于简单的对象形状,两者性能差异可忽略不计。
IDE 体验差异
interface User {
name: string
age: number
}
type Person = {
name: string
age: number
}
// 悬停在 User 上显示: interface User
// 悬停在 Person 上显示: type Person = { name: string; age: number; }
interface 名称常在错误提示中直接展示,可读性更好;type 展开成员虽然信息更多,但复杂类型可能让错误信息冗长。
选型建议
💡 最佳实践:
- 优先使用
interface描述对象 / 类的公共 API(可扩展、IDE 友好、编译快)- 使用
type定义联合类型、元组、映射类型、条件类型- 使用
type定义复杂的函数签名(配合泛型时表达能力更强)- 给第三方库"打补丁"时,利用
interface声明合并- 同一个概念只声明一次——不要同时用
interface和type描述同一个形状- 团队中协商并文档化规则,保持代码库内一致
选型决策树:
需要联合/元组/映射/条件类型?
├── 是 → type
└── 否 → 需要声明合并或 extends 继承?
├── 是 → interface
└── 否 → 描述对象/类 API?
├── 是 → interface(优先)
└── 否 → type常见陷阱汇总
| # | 陷阱 | 说明 | 解决 |
|---|---|---|---|
| 1 | 误以为 type 支持声明合并 |
同名 type 直接报错,不会合并 |
用 interface,或将多个 type 合并为交叉类型 |
| 2 | 多余属性检查(Excess Property Checking) | 字面量赋值给接口时,多余的属性会报错 | 使用中间变量、索引签名、或 as 类型断言 |
| 3 | interface 同名合并污染全局 |
在 .d.ts 中不经意的同名接口会全局合并 |
使用 namespace 包裹,或在模块内声明(非全局脚本文件) |
| 4 | 索引签名吞没所有键 | [key: string]: T 要求所有已知属性值类型是 T 的子类型 |
将索引签名的值类型设为足够宽泛的类型(如 unknown) |
| 5 | 交叉类型属性冲突 | { x: string } & { x: number } 导致 x: never,无法构造 |
避免同名属性冲突,或使用 Omit 重命名后交叉 |
| 6 | 循环引用导致类型展开死循环 | 自引用类型嵌套过深时编译报错 | 将递归部分包装在对象属性中(如 type Tree = { value: T; children: Tree[] }) |
多余属性检查示例
interface User {
name: string
age: number
}
// 字面量直接赋值 —— 报错
// const u: User = { name: "Willow", age: 18, email: "test@test.com" }
// ❌ 'email' does not exist in type 'User'
// 中间变量赋值 —— 不报错
const obj = { name: "Willow", age: 18, email: "test@test.com" }
const u: User = obj // ✅ 通过(结构性类型检查)
🔬 深入原理:多余属性检查是 TypeScript 的特殊规则,仅在字面量直接赋值给明确类型注解的变量时触发。这是一个实用性设计,旨在捕获拼写错误的属性名。通过中间变量赋值时会绕过此检查,因为此时 TS 只做结构性类型检查。
延伸阅读
- TypeScript Handbook: Interfaces
- TypeScript Handbook: Object Types
- TypeScript Handbook: Declaration Merging
- TypeScript Handbook: Type Aliases