第 3 章 · 接口与类型别名
- 用
interface描述对象结构:必选属性、可选属性、只读属性 - 用索引签名约束「键不固定」的对象
- 理解接口继承
extends与「重复声明合并」这两个 interface 的独门特性 - 分清
interface与type的适用场景,知道该优先用哪个 - 掌握类型断言
as与非空断言!的正确用法与风险
3.1 interface 是什么
描述「一个对象长什么样」,最常用的是 interface:
interface User {
name: string
age: number
email: string
}
它既定义了类型,又声明了这个对象必须包含哪些字段:
const ada: User = { name: 'ada', age: 30, email: 'ada@example.com' } // ✅
const bob: User = { name: 'bob', age: 28 } // ❌ 缺少 email
const carol: User = { name: 'carol', age: 25, email: 'c@x.com', admin: true } // ❌ 多了 admin
TS 判断「能不能赋值」靠结构,不靠名字。只要字段对得上,interface 之间、interface 和对象字面量之间都可以互相满足。这跟 Java/C# 那种「必须实现同一个接口」完全不同。
对象字面量有个额外规则——多余属性检查(excess property check):直接赋给字面量时,多了没声明的属性会报错(上面的 carol 例子)。但如果先放进变量再赋值,这条检查会放宽:
const extra = { name: 'carol', age: 25, email: 'c@x.com', admin: true }
const dave: User = extra // ✅ 不报错(字段齐全,多余属性被容忍)
这个「变量 vs 字面量」的差别常让新手困惑:多余属性检查只对字面量直接赋值生效。
3.2 可选属性与只读属性
3.2.1 可选属性 ?
用 ? 表示「可以没有」:
interface Profile {
name: string
nickname?: string // 可选
}
可选属性给出去后是 string | undefined 的效果:
function greet(p: Profile) {
console.log(p.name)
console.log(p.nickname.toUpperCase()) // ❌ 'nickname' 可能为 undefined
console.log(p.nickname?.toUpperCase()) // ✅ 可选链
}
const p1: Profile = { name: 'ada' } // ✅ nickname 直接没有
const p2: Profile = { name: 'ada', nickname: undefined } // ✅ 也可以显式给 undefined
可选属性在类型层面等价于 string | undefined,但对象的键是否存在取决于你。访问时一律按「可能为 undefined」处理。
3.2.2 只读属性 readonly
用 readonly 表示「初始化后不能再改」:
interface Config {
readonly apiKey: string
readonly debug: boolean
}
const cfg: Config = { apiKey: 'sk-xxx', debug: false }
cfg.apiKey = 'other' // ❌ 只读属性不能赋值
readonly 是编译期检查,不影响运行时——编译成 JS 后只是普通属性。它防的是「代码里不该改的地方乱改」,不是「运行时真的冻结」。
3.3 索引签名:描述「键不固定」的对象
当对象的键事先不确定、但值的类型一致时,用索引签名:
// 一个「字符串 → 数字」的字典
interface ScoreMap {
[name: string]: number
}
const scores: ScoreMap = { ada: 95, bob: 88 }
scores['carol'] = 90 // ✅ 任意字符串键都可以
两种索引签名:
| 写法 | 含义 | 例子 |
|---|---|---|
[key: string]: T | 字符串键 | 字典、配置表 |
[key: number]: T | 数字键 | 数组/类数组 |
索引签名要求所有属性的值的类型都兼容它:
interface Dict {
[key: string]: string
// 如果想加一个 number 属性,会报错:
// age: number // ❌ 属性 'age' 的类型与索引签名不兼容
}
想让「大部分任意 + 个别特定」,需要用联合:[key: string]: string | number。
索引签名的典型用途——配置表 / 字典 / 枚举映射:
const errorMessages: Record<string, string> = {
required: '此项必填',
length: '长度不合法',
}
Record<K, V> 是内置工具类型(第 6 章手写它),等于「键是 K、值是 V 的对象」。
3.4 接口继承与声明合并
这两个特性是 interface 独有、type 没有的。
3.4.1 继承 extends
一个接口可以继承另一个,复用字段:
interface Animal {
name: string
age: number
}
interface Dog extends Animal {
breed: string // 在 Animal 基础上多一个字段
}
const d: Dog = { name: '旺财', age: 3, breed: '柯基' }
可以多继承:
interface Named { name: string }
interface Aged { age: number }
interface Person extends Named, Aged {}
// Person = { name: string; age: number }
继承(extends)表示「is-a」:Dog 是一个 Animal。组合(字段里包含另一个对象)表示「has-a」:Car 有一个 Engine。优先用组合——它更灵活,避免继承层级过深。只有当「确实要求一个类型必须包含另一个的全部字段」时,继承才顺手。
3.4.2 声明合并(declaration merging)
同名 interface 会自动合并字段:
interface Window {
title: string
}
interface Window {
version: string
}
// 合并后:interface Window { title: string; version: string }
这个特性在「给第三方库的类型打补丁」时很有用(第 9 章会用到 declare global 配合它)。
3.5 interface vs type:怎么选
对象类型也可以用类型别名 type 写:
type User = {
name: string
age: number
}
两者在描述对象时几乎等价,区别在于几个边界能力:
| 能力 | interface | type |
|---|---|---|
| 描述对象 | ✅ | ✅ |
| 继承 / 合并 | ✅ extends、同名合并 | ❌ 不支持 |
| 联合 / 交叉 / 元组 / 原始类型别名 | ❌ | ✅ 都能干 |
| 映射 / 条件类型(第 6-7 章) | ❌ | ✅ |
// type 能做的、interface 做不到的:
type Status = 'idle' | 'loading' // 联合
type Point = [number, number] // 元组
type ID = string // 别名
type Maybe<T> = T | null // 泛型别名
描述对象优先 interface(能继承、能合并);需要联合/元组/别名/泛型运算时用 type。两者混用没问题——只要在一个项目里保持一致。别被「必须二选一」的焦虑绑架,TS 官方态度是「按场景挑顺手的」。
一个常见练习——什么时候 type 更合适:
// 一个「要么是加载中,要么是成功数据」的状态
type AsyncState<T> =
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
// 这是可辨识联合(第 4 章主角),只能用 type 表示
3.6 类型断言:as 与 !
3.6.1 类型断言 as
当你比 TS 更清楚一个值的真实类型时,用 as 告诉它「听我的」:
// 从 DOM 取元素,TS 只知道是 HTMLElement
const input = document.querySelector('input') as HTMLInputElement
input.value // 现在 TS 认为是 HTMLInputElement
as 是绕过类型检查的开关。断言错误 = 运行时炸。它只在「你确实比 TS 知道得更多」时使用:
const n = 42
n as string // ⚠️ 不报错但纯属胡来(number 断言成 string)
JSON.parse(x) as User // 合理:外部数据,你校验过结构
两个常见变体:
as const:把字面量冻结为精确类型(第 2 章)as unknown as T:跨越不相容类型时的「双断言」(比如把复杂结构强制转型),慎用,通常是设计有问题
3.6.2 非空断言 !
! 表示「这里我保证不为空」:
const el = document.getElementById('app')!
el.style.display = 'block' // 断言 el 不是 null,跳过判空
! 不是运行时保护! 只是骗过编译器,如果运行时真的为 null,照样崩:
const el = document.getElementById('不存在的id')!
el.click() // 💥 运行时报错:el 是 null
能用收窄(第 4 章)就优先收窄,! 留给「确定存在」的场景。
本章小结
interface描述对象:必选 / 可选?/ 只读readonly- 索引签名处理「键不固定」的对象;注意它会对具体属性值类型产生约束
extends继承与同名合并是 interface 独有能力- 对象用
interface,联合/元组/别名/泛型运算用type as断言和!非空断言是绕过检查的「快捷键」,用对场景、控制影响面
本章练习
去 挑战题库 看看 00004「实现 Pick」的题意——「从类型 T 中选出符合 K 的属性构造新类型」。这需要第 6 章的映射类型,先记着,到时回来秒解。
练习 1 · 继承 vs 组合
分别用 extends 继承和「字段内嵌对象」两种方式建模「管理员用户」(普通用户 + role、permissions),对比两种写法的可扩展性,写下你的选择理由。
练习 2 · 配置对象
用索引签名 + readonly 定义一个「环境配置」:键为字符串,值为 string | number | boolean,且整体只读。给它赋几组值验证。
练习 3 · interface vs type 决策清单
列出你在什么场景会选 interface、什么场景会选 type,每条配一个真实例子。至少各两条。
练习 4 · 可选属性与 strict
定义 interface Option { label?: string; value: number },在 strict: true 下访问 option.label,观察报错;用可选链 ?. 和「先判空」两种方式修复,体会差别。
练习 5 · 安全地断言
对接一个返回 unknown 的解析函数,写一个类型守卫风格的校验函数,只有在 typeof / 字段检查都通过后才 as User,避免无脑断言:
function parseUser(raw: unknown): User | null {
// 用 typeof + in + 字段检查,验证通过才 as User
}