系列专栏:「前端转全栈 · Agent 研发实践」第二章 后续会讲 NestJS 还有中间件的概念,在此之前我们先来回顾一下 TypeScript,主要是熟悉下装饰器的语法,为后续理解 NestJS 做好准备
写在前面
上一期聊了 Node.js 的核心机制,按路线图接下来该进框架了。但实际学下来我发现中间卡着一关:TypeScript 。第一次打开 NestJS 代码时人是懵的------装饰器、依赖注入、constructor 里奇怪的 private,扑面而来的 Java 味儿,用的全是 TS 里我们"知道但不用"的那一半能力。
为什么偏偏在这个时间点要认真复习 TS?因为我越来越强烈地感受到:TypeScript 是 AI Coding 时代的一等公民。
现在我们写代码的方式变了:大量代码由 AI 生成,人负责描述意图和验收。这个协作里,类型就是人和 AI 之间最精确的契约 ------你给一个函数标清了入参出参,AI 生成的实现就歪不到哪去;项目里类型覆盖越完整,AI 拿到的上下文就越准确,生成的代码一次通过率越高。反过来,满屏 any 的项目,AI 也只能跟着瞎猜。类型系统是 AI 时代少数"人写得越认真、机器干得越漂亮"的基建。
所以这篇我把 TS 完整复习了一遍,按「概念 → 代码 → 坑」整理成两大部分:前半篇复习基础语法 (每天真用的那些),后半篇讲为框架打底的三块能力:class、依赖注入这个设计模式、以及装饰器。
全文有一条主线,先送给你:
TS 的类型在编译后会被全部擦除,运行时一点都不剩。
记住这一句,后面所有的"为什么要这样设计"就都有答案了。
一、先建立认知:类型只是编译期的脚手架
TS 本质是给 JS 加了一层编译期的检查脚手架 。tsc 编译完,类型注解、接口、as 断言全部消失,产物就是纯 JS。
这个认知直接推出三个结论:
as断言运行时什么都不做。它不是类型转换,是"我比编译器懂"的口头声明- 类型再严也挡不住运行时错误 。
JSON.parse回来的东西标了User它就真是 User 了?编译器只是信了你的标注 - 凡是要在运行时用类型信息的地方,都必须有编译后还活着的载体------这条是后面理解依赖注入的钥匙,第二部分展开讲
第一部分:基础语法复习
二、类型标注:每天真用的就这些
2.1 注解能省则省,但这几处必须写
ts
let name: string = 'kimi'; // 显式注解
let age = 18; // 推断为 number,注解可省
const PI = 3.14; // const 推断为字面量类型 3.14,不是 number
TS 的推断很强,能推断就不写。但三个地方要显式写:函数返回值、复杂对象、空数组 ([] 会被推断成 any[],之后 push 什么都不检查)。
2.2 strict 模式下,null 和 undefined 不能随便塞
ts
let s: string = null; // ❌ strict 下报错
let s2: string | null = null; // ✅ 显式声明"可能为空"
这是 strictNullChecks 的核心价值:逼你把"可能为空"写进类型里,让 Cannot read properties of undefined 这类线上事故在编译期现形。新项目 strict 没有任何理由不开。
2.3 数组与元组
ts
const list: string[] = ['a', 'b'];
const matrix: number[][] = [[1, 2]];
// 元组:定长、定类型、定顺序
const pair: [string, number] = ['age', 18];
坑 :push 不受元组长度限制(pair.push('x') 编译不报错)------元组只保证"读取和解构时"的类型,这是 TS 已知的类型不健全点,别让 AI 生成的代码依赖元组的长度安全。
2.4 对象类型与多余属性检查
ts
type User = {
name: string;
age?: number; // ? 可选属性
readonly id: string; // 只读(编译期约束,运行时照常能改)
};
坑 :对象字面量直接传参会触发多余属性检查(excess property check);先赋给变量再传就跳过了------拼错的字段会被悄悄忽略:
ts
greet({ id: '1', name: 'a', nme: 'b' }); // 报错:nme 是多余属性
const u = { id: '1', name: 'a', nme: 'b' };
greet(u); // 不报错(nme 悄悄被忽略)
2.5 函数类型
ts
// 完整写法
const add: (a: number, b: number) => number = (a, b) => a + b;
// 可选参数、默认值、rest 参数
function build(name: string, age?: number, ...tags: string[]) {}
坑 :age?: number 和 age: number | undefined 不等价------前者可以不传 ,后者必须传一个 undefined。定义回调签名时这个区别经常遇见问题。
2.6 联合类型与字面量:TS 最高频的写法
ts
type Status = 'pending' | 'success' | 'failed';
function setStatus(s: Status) {}
setStatus('success'); // ✅
setStatus('done'); // ❌ 编译报错
后面所有的类型收窄、判别字段(discriminated union)都建立在联合类型上,务必熟练。
2.7 enum:枚举
ts
enum Direction { Up, Down, Left, Right } // 数字枚举,默认从 0 起
Direction[0]; // 'Up' ← 双向映射,行为反直觉,打包体积还大
我们一般也不会这么用,都会给定义好枚举值
2.8 断言 as:绕开编译器,不是类型转换
ts
const data = JSON.parse('{}') as User; // 编译产物里这个 as 完全消失
纪律:能用收窄解决就不用 as ;as unknown as X 双重断言基本等于作弊,Code Review 应当拦下。AI 生成的代码里 as 出现频率偏高,验收时重点扫这个关键字。
三、any、unknown、never:三个特殊货
any:编译器彻底下班。
标了 any 的变量,能装任何值、能赋给任何人、能调任何方法------类型检查对它完全关闭。所以 any 的真正危害不是"不检查",是会传染:
ts
const data: any = JSON.parse('{}');
const name: string = data.user.name; // 编译通过,运行时可能直接炸
data 流向的每一处,类型检查全部失效,错误被推迟到运行时。而且一个 any 标记,会让 AI 拿到的上下文在这一片全部失真------这是 AI Coding 时代 any 的新代价。团队里建议直接用 ESLint 的 @typescript-eslint/no-explicit-any 禁掉。
unknown:安全版的 any。
它和 any 一样能装任何值,但反过来:你不能把它直接赋给别人,也不能直接调它的方法------想用,先收窄:
ts
let u: unknown = JSON.parse('{}');
if (typeof u === 'object' && u !== null) {
// 收窄之后才能用
}
实战规则:API 响应、JSON.parse、catch 的 error(开了 useUnknownInCatchVariables 之后默认就是 unknown),一律标 unknown,逼自己走一遍收窄。多花两行代码,换类型检查重新上岗,值。
never:什么都装不了的类型。
它是所有类型的子类型------能赋给任何类型,但没有任何值能赋给它。日常用到它的场景有两个:一是给"永不返回"的函数(抛错、死循环)当返回类型;二是穷举检查:
ts
function handle(s: Status) {
switch (s) {
case 'pending': return;
case 'success': return;
case 'failed': return;
default:
const exhaustive: never = s;
throw new Error(`unhandled: ${exhaustive}`);
}
}
所有分支都处理完,能走到 default 的 s 理论上"不存在",标成 never 让编译器帮你守着;哪天 Status 加了新成员,这行立刻编译报错,提醒你回来补分支。
四、type 还是 interface?(面试高频,一次讲透)
先说相同点:都能描述对象结构、都能描述函数、都能被扩展。光看这些会觉得它俩可以互换------确实大量场景能互换,真正的区别藏在下面三点。
区别一:能描述的范围不同。
interface 只能描述"对象结构";type 是类型别名,什么类型都能起名字------联合、元组、函数、基本类型都行:
ts
type ID = string | number; // 联合类型,interface 做不到
type Pair = [string, number]; // 元组,interface 做不到
type Callback = (x: number) => void; // 函数类型,interface 能写但长得很别扭
区别二:interface 会声明合并,type 不会。
同名的 interface 写两次,自动合并成一个;同名的 type 写两次,直接报错:
ts
interface User { name: string }
interface User { age: number }
// 现在的 User = { name: string; age: number }
这个特性乍看有点怪,但它正是 interface 最重要的超能力,下面单独讲。
区别三:扩展的写法不同,报错的时机也不同。
interface 用 extends,type 用 & 交叉:
ts
interface Admin extends User { role: string }
type Admin = User & { role: string };
效果接近,但处理冲突时不一样:extends 遇到同名属性类型不兼容会当场报错;& 会默默把冲突属性交叉成 never,等到用的时候才发现,排查起来更绕。
选择约定:描述对象结构优先 interface(能合并、错误提示更友好);联合、函数、复杂组合用 type。面试如果再被追问一句"为什么第三方库的类型定义几乎都用 interface",答案就落在声明合并上。
4.2 声明合并:给改不了源码的类型打补丁
想象一个场景:你在用一个库,想给它暴露出来的某个类型加一个字段。库的类型你改不了源码;extends 一个子类型?也不行------库里所有函数接收和返回的还是旧类型,你的子类型塞不回去。
声明合并就是干这个的:不动库的源码,直接往它的 interface 里追加成员:
ts
// 经典场景:给 Express 的 Request 加 user 字段
declare module 'express' {
interface Request {
user?: { id: string };
}
}
// 之后项目里所有 req.user 都有类型提示了,库本身一行没改
和 extends 的本质区别:extends 是造一个新类型 (调用方得换名字);声明合并是增强旧类型本身(所有用到它的地方自动升级)。
它比"往 window 上挂全局变量"那套野路子安全,靠的是三道护栏:
- 编译期生效,产物无痕迹
- 同名成员类型冲突 → 直接报错(只能增补,不能覆盖)
- 默认模块作用域 隔离,跨文件需显式
declare module/declare global
纪律 :默认不用;只在打补丁时用;补丁集中在 types/*.d.ts;declare global 慎用。
五、类型收窄与类型谓词
内置守卫:typeof、instanceof、in、判别字段(code === 0)。但有一个经典场景内置守卫搞不定:判断逻辑抽成了函数 ------函数返回 boolean,TS 看不懂它和类型的关系。这时需要类型谓词:
ts
function isCat(animal: Cat | Dog): animal is Cat { // ← 给编译器的承诺书
return 'meow' in animal;
}
if (isCat(animal)) {
animal.meow(); // ✅ 收窄成功
}
三要素:参数名对应、窄类型与宽类型相容、函数体返回 boolean。
最高频的实战场景------数组 filter 收窄:
ts
const list: (string | null)[] = ['a', null, 'b'];
const r = list.filter((x): x is string => x !== null); // string[]
⚠️ 编译器不验证承诺的真假,判断逻辑必须自己写对。这条在验收 AI 生成的谓词函数时尤其重要------签名写得再漂亮,函数体错了编译器也不拦。
六、泛型:类型层面的"函数参数"
ts
function first<T>(arr: T[]): T {
return arr[0];
}
// 泛型约束:限制 T 必须有 length
function logLen<T extends { length: number }>(arg: T): T {
console.log(arg.length);
return arg;
}
// 泛型默认值
interface ApiResponse<T = unknown> {
code: number;
data: T;
}
理解泛型只需要一个类比:普通函数抽象"值",泛型抽象"类型" 。T 就是类型层面的形参,extends 是参数校验,= unknown 是默认参数。
服务端代码里泛型无处不在:Promise<User[]>、ApiResponse<T> 这种响应包装、数据库层的 Repository 模式。后面读框架源码时满眼都是它,这关必须过。
第二部分:为 NestJS 打底的 TS 能力(重点)
下一期才正式讲 NestJS,所以这部分我不用框架里的具体概念,只讲三样前置能力:class、依赖注入这个设计模式、装饰器。学完你会发现,框架里那些看起来像魔法的东西,全是这三样的组合。
七、class 复习:从 ES6 到 TS
7.1 先回忆 ES6 class 长什么样
ts
class Animal {
name: string; // 实例属性(挂在 this 上,每个实例各一份)
constructor(name: string) { // new 时执行的装配车间
this.name = name;
}
speak(): string { // 方法(挂在原型上,所有实例共享一份)
return `${this.name} makes a sound`;
}
}
const a = new Animal('cat');
三个认知:
constructor是 new 时的装配车间,this.xxx = xxx把参数装到实例上- 方法定义在类体里 → 在原型 上,全实例共享;属性在 constructor 里赋值 → 在实例上,各自一份
- class 同时是值 (能 new、能当参数传来传去)和类型 (能标注变量,
let b: Animal)------interface 做不到第一点,这是分水岭,7.5 细说
7.2 继承、super 与 override
ts
class Dog extends Animal {
breed: string;
constructor(name: string, breed: string) {
super(name); // 子类 constructor 里必须先调 super 才能碰 this
this.breed = breed;
}
override speak(): string { // override:明确声明"我在覆盖父类方法"
return `${this.name} barks`;
}
}
super(...)调父类 constructor;super.speak()调父类方法instanceof沿原型链判断:dog instanceof Animal→true- 开
noImplicitOverride后,覆盖父类方法必须写override------防止父类改了方法名,子类这边静默失效
后面学框架时,自定义错误处理、自定义管道经常要继承框架给的基类,用的就是这套。
7.3 static:挂在类本身上
ts
class Config {
static env = process.env.NODE_ENV; // 类属性,不走实例
static create() { return new Config(); } // 工厂方法惯例
}
Config.env; // 不需要 new
7.4 TS 给 class 加的三样东西
ES6 class 之上,TS 又加了几样,每一样后面都天天见。
参数属性:声明属性、接收参数、赋值 this,三合一:
ts
class UserService {
constructor(private readonly db: Database) {}
}
这一行顶过去三行代码。后面看框架代码,所有"注入"的写法都长这样,认识它就能读懂一大半。
修饰符 :private / protected / readonly------注意它们全是编译期约束 ,运行时产物里没有真正的私有(真正的私有是 JS 原生的 #field 写法)。
implements 与 abstract:
ts
// implements:约束类必须实现接口结构(编译期检查,运行时不存在)
interface Payable { pay(amount: number): void }
class Alipay implements Payable {
pay(amount: number) { /* ... */ }
}
// abstract:不能实例化,留给子类实现
abstract class BaseRepository {
abstract find(id: string): unknown;
}
7.5 关键分水岭:class 是值,interface 只是类型
回到全文主线。interface 编译后完全消失,运行时不存在;class 编译后还在------它既是一个值,又是一个类型。
由此得出一个重要推论:框架想在运行时根据"类型"做点什么事,唯一的选择就是 class。下一节讲依赖注入,马上就会用到这个结论。
八、依赖注入(DI):一个设计模式,不是框架专利
NestJS 代码里最扎眼的就是"依赖注入",但它不是框架发明的黑话,而是一个朴素的设计模式。我们从零推一遍。
写服务端代码,类之间一定有依赖:UserService 要查数据库,Database 要读配置。最朴素的写法是自己 new:
ts
class Database {
query(sql: string) { /* ... */ }
}
class UserService {
private db = new Database(); // 依赖由自己创建
}
小项目这么写没问题。项目大了,三个麻烦:
- 换实现要改源码 。
Database想换个实现,每个new它的地方都得跟着改 - 测试没法造假 。想单测
UserService又不想真连数据库?依赖写死在类内部,假的塞不进去 - 依赖链全靠手工维护。A 依赖 B、B 依赖 C......每层都自己 new,谁先谁后、是不是同一个实例,全靠自己操心
依赖注入的思路很直白:类不自己造依赖,只在构造函数里声明"我需要什么";剩下的交给一个容器------由它统一 new、按依赖顺序组装、从构造函数传进来:
ts
class UserService {
constructor(private db: Database) {} // 只声明需求,不关心怎么造
}
// 容器替你干的事(伪代码):
const db = new Database();
const userService = new UserService(db);
创建对象的控制权,从"类自己"手里反转给了"容器"------所以 DI 又叫控制反转(IoC) 。上面三个麻烦随之解决:换实现,改容器的登记就行;测试,往容器里塞个假的 Database;依赖再多,装配顺序容器自己算。
现在可以把上一节的分水岭接上了:容器是运行时干活的,它怎么知道 UserService 需要一个 Database? 直觉上靠的就是 constructor 上那个类型标注------可类型不是会被擦除吗?所以这里需要两个东西配合:依赖必须用 class 来标(编译后还在),再加一个能把类型信息"抢救"下来的编译开关。这个开关第十节讲,先记住有这回事。
九、装饰器:这篇的主角
铺垫了这么久,终于到正主。NestJS 满天飞的 @ 语法就是它,这节把它彻底拆一遍。
9.1 装饰器到底是什么
剥掉语法糖,装饰器就是一个函数 ,@ 只是调用它的特殊写法。关键特征:它在类被定义的时候执行一次,跟有没有 new 实例毫无关系:
ts
function Log(target: any, key: string) {
console.log(`给方法 ${key} 贴了个标签`);
}
class Demo {
@Log
hello() {}
}
// 这段代码一加载就打印:给方法 hello 贴了个标签
// 注意:一次 new 都没发生
它能贴的位置有五种:类、方法、属性、方法的参数、访问器。贴的位置不同,函数收到的参数也不同:贴在类上收到类本身;贴在方法上收到(类、方法名、属性描述符);贴在参数上再多一个参数下标。
9.2 带括号的是装饰器工厂
@Log 是直接用这个函数;@Log('xxx') 带括号的,是先调用 Log('xxx'),把它返回的函数当成真正的装饰器------参数靠闭包带进去:
ts
function Log(msg: string) {
return function (target: any, key: string) {
console.log(`${msg}: ${key}`);
};
}
class Demo {
@Log('审计')
hello() {}
}
9.3 执行顺序
三条规则,自己跑一遍就记住了:
less
多个装饰器叠在一处:从下往上执行(洋葱模型,@A @B 作用于 x 等价于 A(B(x)))
同一个类里:实例成员 → 静态成员 → 类装饰器最后
方法和它的参数:参数装饰器先于方法装饰器
ts
function A(target: any, key?: string) { console.log('A'); }
function B(target: any, key?: string) { console.log('B'); }
@A // 类装饰器,后执行
class Demo {
@B // 方法装饰器,先执行
hello() {}
}
// 输出:B → A
9.4 装饰器 + 元数据:框架自动化的全部原理
装饰器本身只是"在定义类时执行了一个函数",真正让它有用的是配合 reflect-metadata 这个库,往类上存数据、读数据:
ts
import 'reflect-metadata';
function Route(path: string) {
return function (target: any, key: string) {
Reflect.defineMetadata('path', path, target, key); // 往类上存一条数据
};
}
class Demo {
@Route('/hello')
hello() {}
}
// 框架启动时:扫一遍所有的类,把标签读出来,按标签办事
const path = Reflect.getMetadata('path', Demo.prototype, 'hello'); // '/hello'
所谓框架的"自动装配",拆开就是三步:装饰器贴标签 → 启动时 Reflect 读标签 → 按标签注册和组装。NestJS 的路由注册、依赖注入全是这个套路,下一期我们完整地拆它。
9.5 类比校准
装饰器不是 slot(留坑填内容),它更像 React HOC、Koa 洋葱、axios 拦截器------给已有的东西包一层壳。
9.6 避坑:TS 有两代装饰器
- 旧版 experimental:支持参数装饰器,NestJS 用的就是这套
- TS 5.0+ 新标准:不支持参数装饰器
网上资料对不上时,先确认对方讲的是哪一代。tsconfig 里的 experimentalDecorators: true,就是显式选择旧版。
十、tsconfig:把前面讲的全部串起来
jsonc
{
"compilerOptions": {
"strict": true, // 严格模式全家桶,必开
"experimentalDecorators": true, // 启用旧版装饰器(NestJS 需要)
"emitDecoratorMetadata": true, // 把参数类型贴成元数据------DI 的魔法来源
"target": "ES2021",
"module": "CommonJS" // Node 服务端惯例
}
}
第八节留了个悬念:constructor 上的类型标注会被擦除,容器运行时怎么知道每个类需要什么依赖?答案就是 emitDecoratorMetadata:开了它,编译器会自动把装饰器目标的类型信息存成元数据 (design:paramtypes)。运行时 Reflect.getMetadata 一读,"UserService 需要一个 Database"这件事就被抢救下来了。
所以这份配置里每一项都对应前面讲过的机制:strict 对应第二章,experimentalDecorators 对应第九节,emitDecoratorMetadata 对应第八章的悬念。没有玄学。
写在最后
回顾这篇的主线:
- 类型在编译后被全部擦除------TS 只是编译期的脚手架
- 所以
as不转换数据,interface 运行时不可查,运行时要用类型只能靠 class - 依赖注入容器在运行时工作,靠 class 标注 + 元数据才知道每个类需要什么
- 装饰器贴标签,Reflect 读标签,
emitDecoratorMetadata负责把类型抢救下来------自动装配的全部真相
再呼应下开头:AI Coding 时代,TS 的价值反而被放大了。类型是你和 AI 之间最精确的契约,是生成质量的护栏,也是验收生成代码时最先该扫的东西。把 TS 功底打扎实,是这个时代投入产出比最高的学习之一。
下一期正式进框架:Express、Koa、NestJS 三代框架的中间件模型是怎么演进的?为什么公司里的框架长得像 NestJS?这篇埋的"装饰器 + 元数据"伏笔,到时候全部回收。
最后留个小问题:你在让 AI 生成代码时,有没有因为类型标注不清而翻过车?评论区聊聊。