本页定位 · TypeScript

JavaScript 灵活但"运行时才报错",项目一大就失控。TypeScript 给 JS 加了一套静态类型系统——类型既是文档、又是编译期保险。本页不重复 JS 基础(见前端基础页),聚焦 类型思维:从 any 陷阱到泛型、联合/交叉、类型体操、装饰器,再到 tsconfig、.d.ts、与框架结合、编译运行时的真相。我会把每个点拆成"是什么 → 为什么 → 怎么用 → 踩什么坑"。基线:TypeScript 5.x(tsc / tsx)。

1

类型系统基础:推断、字面量、联合 / 交叉

Type Inference · Literal · Union · Intersection

TS 不是"给每个变量写类型"那么无聊。它最便宜的能力是自动推断,最值钱的能力是把"可能"精确表达出来。这一章是后面一切的地基。

类型推断与控制流分析

TS 大部分时候能从字面量反推类型,你不必处处标注。更厉害的是控制流分析:它会在 if、赋值、返回之后,缩小变量的类型范围——所以下面的 name 在 if 里被判定为 string,不会让你调 toUpperCase 时报错。

let n = 1; // 推断为 number,不是 any n = 'x'; // 报错:不能把 string 赋给 number function f(x: string | null) { if (x === null) return; // 之后 x 被收窄为 string console.log(x.toUpperCase()); // 安全,TS 知道这里 x 必为 string }

论为什么推断比标注更省心

标注类型要手写两遍(声明 + 用到的地方),推断让编译器替你干。但函数参数和导出 API 建议显式标注——那是"对外契约",写清楚能让调用方和编译器都有依据,也避免推断出的类型比你想的更宽。

字面量类型与 const 断言

TS 里 'success' 不只是 string,它是一个字面量类型——只等于这个具体值。配合 as const 能把整个对象"冻"成只读的字面量集合,常用来定义配置/枚举。

let s = 'ok'; // 推断为 string(可重新赋别的字符串) const t = 'ok'; // 推断为字面量类型 'ok'(再赋别的值报错) type Status = 'ok' | 'error'; // as const:对象整体变 readonly,数组变只读元组 const config = { env: 'prod', retries: 3 } as const; config.env = 'dev'; // 报错:readonly

联合类型与收窄(typeof / instanceof / in)

联合类型 A | B 表示"可能是其中任一种"。光联合不够,你得在运行前收窄到具体类型才能安全调用它的专属方法。三种常用收窄手段:

收窄手段适用写法
typeof基本类型typeof x === 'string'
instanceof类实例x instanceof Date
in对象区分'role' in x
字面量判别可辨识联合x.kind === 'a'
type Id = string | number; function format(id: Id) { if (typeof id === 'string') return id.toUpperCase(); // 这里 id 是 string return id.toFixed(2); // 这里推断为 number }

交叉类型:把多个类型"拼"在一起

交叉类型 A & B 表示"同时具备 A 和 B 的全部成员"——常用来给已有类型叠加字段,比继承灵活。

interface Base { id: number; } interface Timestamps { createdAt: Date; updatedAt: Date; } type Row = Base & Timestamps; // 同时有 id + 两个时间 // 合并对象用扩展运算符,类型用交叉(语义不同) const row: Row = { id: 1, createdAt: new Date(), updatedAt: new Date() };
交叉同名但类型冲突 = never

若 A 和 B 都有 x 但类型不兼容(如 string 与 number),交叉后 x 变成 never——意味着这个类型不可能被满足。同名属性要么类型一致,要么用联合而非交叉。

泛型基础:通吃类型又不丢信息

泛型让同一份逻辑适配多种类型,且保留具体类型。关键区别于 any:泛型在调用时把具体类型"接住",后续操作仍带类型检查。

function first<T>(arr: T[]): T | undefined { return arr[0]; } const a = first(['x', 'y']); // a: string | undefined const b = first([1, 2]); // b: number | undefined // 带约束:T 必须有 length,否则编译期报错 function logLen<T extends { length: number }>(x: T): T { console.log(x.length); return x; }

论泛型 vs any 的本质区别

any 把类型信息整个丢掉了,函数返回后编译器一无所知;泛型在调用点把 T 实例化成真实类型(如 string),返回值和后续用法都还带检查。要"通吃"又不想"失明",用泛型不用 any。

类型断言 as 与非空断言 !

有时你比编译器更清楚类型,用 as 断言"这就是某类型"。但断言是"关掉检查",用错运行时才炸。还有非空断言 !,告诉编译器"这里绝不为 null/undefined"。

const el = document.getElementById('x'); // 推断 HTMLElement | null el!.focus(); // ! 断言非 null(若实际是 null 运行时空指针) const img = el as HTMLImageElement; // as 断言成子类 // 双重断言(尽量少用):先 as unknown 再 as 目标,绕过不兼容检查 const x = y as unknown as string;
as 不是"转换",只是"骗编译器"

as 不产生任何运行时代码,它只是对你(和编译器)说"信我"。如果实际上类型不对,运行时该错还错。number as string 这种跨家族断言必须先 as unknown,因为它本质上不安全。能用类型守卫(is X)就别用 as。

结构化类型:TS 怎么判断"类型相等"

TS 用结构化类型(鸭子类型)而非名义类型:两个类型只要"形状兼容"就认为可互相赋值,不关心名字。这点和 Java/C#(名义类型)相反,常让新手困惑。

场景TS 行为说明
多字段赋少字段允许{a,b} 可赋给需要 {a} 的地方
少字段赋多字段报错缺属性不满足形状
同形状不同名允许结构一致即兼容(鸭子类型)
想按"名字"区分用 branded typetype USD = number & {__brand}

论结构化类型让"组合"更自然

名义类型里,两个不同名的同类结构不能互用,得写转换。结构化类型下,只要字段对得上就能传,写工具函数/组合更顺。代价是"同名不同义"的类型可能被误当兼容——敏感场景(金额/ID)用 branded type 打上不可见标记,强制区分。

2

类型体操:条件 / 映射 / infer / 递归 / 模板字面量

Conditional · Mapped · infer · Recursive · Template Literal

"类型体操"是用类型系统本身做计算:在编译期由输入类型推导出输出类型。业务开发够用即可,但写库、工具链、DSL 时它是硬通货。这一章把几个核心原语讲透。

条件类型:T extends U ? X : Y

条件类型像类型的 if:如果 T 可赋值给 U,结果是 X,否则 Y。常配合泛型做"类型分发"。

type IsString<T> = T extends string ? 'yes' : 'no'; type A = IsString<string>; // 'yes' type B = IsString<number>; // 'no' // 分发:裸类型参数遇联合会"逐成员计算" type ToArray<T> = T extends any ? T[] : never; type C = ToArray<string | number>; // string[] | number[]
分发只在"裸类型参数"上发生

把 T 包进数组/元组(如 [T] extends [U])会关掉分发,当整体判断。想要分发就保持 T extends U 裸着;想要整体判断就加方括号。这是 90% 类型体操 bug 的来源。

infer:在条件类型里"提取"一部分

infer 让你在 extends 的模式里"占位并捕获"某个子类型,相当于类型层面的"解构赋值"。

// 提取数组元素类型 type Elem<T> = T extends (infer E)[] ? E : T; type X = Elem<number[]>; // number // 提取函数返回类型(内置 ReturnType 就是这么写的) type MyReturn<F> = F extends (...args: any[]) => infer R ? R : never; type Y = MyReturn<() => boolean>; // boolean

论infer 是类型体操的"瑞士军刀"

数组元素、函数返回值/参数、Promise 内层、字符串分段……几乎所有"从一个复杂类型里抠出一块"的需求,都靠 infer。它和元组、条件类型组合,能表达非常精巧的类型变换。

映射类型:遍历键,批量改造

映射类型用 keyof + 索引签名,遍历一个类型的所有键,对每个键做改造(加可选、加只读、换值类型)。utility types 大多建立在它之上。

// 自己造一个 Partial:所有字段变可选 type MyPartial<T> = { [K in keyof T]?: T[K]; }; // 造 Readonly:所有字段变只读 type MyReadonly<T> = { readonly [K in keyof T]: T[K]; }; interface User { id: number; name: string; } type U = MyPartial<User>; // { id?: number; name?: string }

递归类型:深只读 / 深 Partial

内置 Readonly 只处理一层。要"递归到所有嵌套对象",让类型在定义里引用自己即可——TS 支持类型层面的递归(有深度上限,约 50 层)。

type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]; }; const cfg: DeepReadonly<{ a: { b: { c: number } } }> = ...; cfg.a.b.c = 1; // 报错:嵌套层也被冻住了
递归别无限展开大对象

递归类型在实例化时会真的"展开"每一层,超深的对象会让编译器超时或触达深度上限报错。业务里多数情况用一层 Readonly 就够了,深递归留给确实需要的配置/状态快照场景。

模板字面量类型:用字符串拼出类型

TS 支持"模板字面量类型"——像 JS 模板字符串那样,把字面量类型拼接成新类型。最实用的是自动生成事件名 / CSS 属性名这类有规律的字符串类型。

type Event = 'click' | 'hover'; type Handler = `on${Capitalize<Event>}`; // 'onClick' | 'onHover' // 拼路径类型:保证 key 一定是 "user.id" 这种点分形式 type Path = `user.${'id' | 'name'}`; // 'user.id' | 'user.name'

论类型体操的边界

模板字面量、递归、infer 组合起来能做很炫的事(如根据 SQL 生成类型安全的查询构造器)。但记住:类型越复杂,编译越慢、同事越看不懂。业务代码里用现成 utility types 即可,类型体操留给库作者。

内置 utility types 速查:少写重复类型

TS 自带一批"类型层面的小工具",覆盖绝大多数日常变换。记熟它们,能省下大量手写映射类型。

工具作用示例
Partial<T>所有字段变可选更新 DTO
Required<T>所有字段变必填补全默认值后
Pick<T, K>只取部分字段列表预览对象
Omit<T, K>排除部分字段去掉 password
Record<K, V>构造键值映射字典对象
ReturnType<F>取函数返回类型复用返回结构
Awaited<T>取 Promise 内层async 结果类型

论utility types 是"类型体操的成品"

你手写映射类型练手能加深理解,但业务里直接调 Pick/Omit/Partial 可读性更高、别人也认得。类型体操的功夫,留给那些"现成工具做不到"的定制变换。

3

装饰器与元编程

Decorators · TC39 · reflect-metadata

装饰器用 @xxx 给类/方法/属性"贴标签",由框架读取并注入行为。NestJS、Angular、Spring 风格的后端都靠它做依赖注入与 AOP。TS 5 后走向 TC39 标准装饰器,旧项目还有 experimentalDecorators 模式——两者写法有差异,别混。

装饰器是什么:给代码"贴标签"

装饰器本质是一个在定义时被调用的函数,它接收"被装饰的目标"(类/方法/属性),可以读取、替换或增强它。比继承/混入更声明式。

// 类装饰器:给类加一个静态标记 function Seal(ctor: Function) { Object.seal(ctor); Object.seal(ctor.prototype); } @Seal class User { name = ''; }

方法 / 属性 / 参数装饰器

不同位置的装饰器拿到不同参数。方法装饰器能拿到"原型、方法名、属性描述符",常用来包裹原方法(加日志/缓存/鉴权)。

// 方法装饰器:记录调用耗时(AOP 式横切) function Log(_t: object, key: string, descriptor: PropertyDescriptor) { const original = descriptor.value; descriptor.value = function (...args: any[]) { console.time(key); const r = original.apply(this, args); console.timeEnd(key); return r; }; } class Svc { @Log do() { /* ... */ } }

论装饰器最适合"横切关注点"

日志、缓存、鉴权、重试、事务——这些和"业务逻辑本身"无关、却要套在多个方法上。装饰器让你把这些"横切"抽出来复用,业务方法保持干净。这就是 AOP(面向切面编程)在前端的落地。

TC39 标准装饰器 vs 旧 experimentalDecorators

2023+ 的 TS 支持TC39 标准装饰器(语法更像 JS 提案,用 (value, context) => 形式),而老项目用的是 experimentalDecorators + emitDecoratorMetadata。两者不兼容,迁移要注意。

项experimentalDecorators(旧)TC39 标准(新)
开启tsconfig 开 experimentalDecoratorstarget ≥ ES2022 默认支持
签名靠参数位置区分目标统一 (value, context)
元数据依赖 emitDecoratorMetadata自己显式声明
框架NestJS 旧版、AngularNestJS 新、新项目
两套装饰器别混用

同一项目若同时开 experimentalDecorators 又写 TC39 语法,编译会冲突或行为诡异。新项目直接用标准装饰器;维护老 NestJS 项目时保持旧模式,升级前查框架文档确认支持到哪套。

反射 reflect-metadata 与依赖注入

reflect-metadata 让装饰器在运行时读取类型信息(通过 emitDecoratorMetadata 把参数类型写进元数据),依赖注入容器据此自动 new 出依赖。NestJS 的 @Injectable 就是这套机制。

// 依赖注入示意:容器根据类型元数据自动装配 @Injectable() class UserService { constructor(private repo: UserRepo) {} } // 容器看到 constructor 需要 UserRepo,自动实例化并注入 const svc = container.get(UserService); // repo 已就位

实战:用装饰器做缓存与权限

两个高频场景:① 方法结果缓存(相同参数直接返回);② 接口权限校验(没权限抛错)。都是"包一层"的套路。

// 简易方法缓存装饰器 const cache = new Map<string, unknown>(); function Cache(_t: object, key: string, d: PropertyDescriptor) { const fn = d.value; d.value = (...args: any[]) => { const k = key + JSON.stringify(args); return cache.has(k) ? cache.get(k) : (cache.set(k, fn(...args)), cache.get(k)); }; }

论装饰器不是银弹

装饰器让代码声明式,但叠加太多会"行为藏在注解里",新人读不懂"这个方法到底被套了什么"。装饰器逻辑要有单测覆盖,且每个装饰器只做一件事。别用它替替换可读性更差的魔法。

装饰器的执行顺序:初始化时从上到下

多个装饰器叠在同一目标上,执行顺序是工厂函数从下往上求值、装饰器本身从上往下应用。理解它,才能预测"谁先跑、副作用顺序对不对"。

// 求值顺序:@f @g 时,f 的工厂先被调用求值,g 后; // 但"应用到类"时 @f 在上先应用。简单记:下→上求值,上→下应用 function Log(label: string) { console.log('工厂求值:' + label); return (ctor: Function) => console.log('应用到:' + label); } @Log('A') @Log('B') class Demo {} // 输出:工厂求值:A → 工厂求值:B → 应用到:A → 应用到:B
顺序错会踩"依赖未就绪"

如果两个装饰器一个注入依赖、一个读取依赖,顺序反了就会读到 undefined。写装饰器时别假设顺序"恰好对",要么让装饰器彼此独立,要么显式约定顺序并在注释里写清。

4

工程:tsconfig、.d.ts、第三方类型

tsconfig · strict · paths · .d.ts

写对 TS 不等于"能编过"。tsconfig.json 决定严格度、模块解析、产物形态;.d.ts 是"给无类型 JS 包补说明书";第三方类型怎么找、怎么补,是日常高频操作。

tsconfig 关键项:strict / paths / moduleResolution

三个最常动的项:strict(全严格检查)、paths(路径别名)、moduleResolution(怎么找模块)。配错一个,满屏报错或运行时找不到模块。

配置作用推荐
strict打开全套严格检查true
noImplicitAny禁止隐式 anytrue
moduleResolution模块查找策略bundler / node16
paths路径别名@/* → src/*
verbatimModuleSyntax强制 type 导入显式true(新项目)
// tsconfig.json 关键项 { "compilerOptions": { "strict": true, "noImplicitAny": true, "target": "ES2022", "moduleResolution": "bundler", "paths": { "@/*": ["src/*"] } } }
strict 一开始会"红成一片"

开 strict 会暴露大量你以前忽略的隐患(可能为 null、隐式 any)。忍住别关,逐个修掉——这恰是 TS 在替你还技术债。新项目一律 strict 起步;老项目用 tsc --strict --noEmit 渐进开。

编译目标与模块解析:bundler vs node16

moduleResolution 选错,import 在 TS 里能过、运行时却找不到模块。用打包器(Vite/webpack)选 bundler;写 Node 运行时选 node16/nodenext,它会严格按 package.json 的 exports 和 type 解析。

// NodeNext 下:package.json 的 type 决定 .js 是 CJS 还是 ESM // 且扩展名必须写全(不能省 .js) import { foo } from './foo.js'; // NodeNext 必须带 .js // bundler 模式则允许省略扩展名、用 paths 别名

论为什么 Node 要写全扩展名

Node 运行时没有"解析器"帮你猜 ./foo 是 .ts 还是 .js。TS 在 NodeNext 模式下强制写全,是为了让"编译后的 .js"能直接在 Node 跑通。打包器模式因为有 bundler 兜底,所以可以省略。

类型声明 .d.ts 与 declare

遇到没类型的 JS 库,或要描述全局变量/环境,用 .d.ts 写"只有类型、没有实现"的声明。declare 告诉编译器"这个东西存在,别报找不到"。

// 给一个无类型的模块补声明:types/my-lib.d.ts declare module 'my-untyped-lib' { export function doThing(input: string): number; } // 声明全局变量(如注入到 window 的字段) declare global { interface Window { __APP_ID__: string; } }

第三方类型:@types 与类型查询

绝大多数流行库的类型在 @types/xxx(DefinitelyTyped)或自带 types 字段。装错/漏装,import 时就报 Could not find a declaration file。

# 装运行时 + 类型(若类型在 @types 里需单独装) npm i lodash npm i -D @types/lodash # 类型补充包 # 类型自带在包里的(如 zod、react),不用额外装 # 查某库有没有类型:npm info @types/lodash 或看包的 types 字段
临时绕过类型缺失的"债务"

实在没类型又赶时间,有人写 // @ts-ignore 或 declare module 'x'; 糊弄。这是债务:@ts-ignore 会压制下一行所有报错,连真错一起压。优先用 // @ts-expect-error(只有确实该报错被消除时才合法),或正经写 .d.ts。

ambient module 与全局类型

除了给具体模块补类型,ambient 声明还能描述"整个环境"(如 import.meta.env、CSS 模块、静态资源导入)。Vite 项目一般有一份 vite-env.d.ts 做这事。

// vite-env.d.ts /// <reference types="vite/client" /> // 之后 import.meta.env.BASE_URL 等才有类型 // 描述 "*.svg" 导入返回字符串 declare module '*.svg' { const src: string; export default src; }

论类型缺失时先问"谁该负责"

优先让库自带类型(提 issue / 用有类型的替代品);其次装 @types;最后才自己写 .d.ts。自己补的声明要窄而准,别一股脑 any,否则补了等于没补。

tsc 输出结构与增量编译

tsc 默认按 outDir 镜像源目录结构输出 .js;开 --incremental 会写 .tsbuildinfo,下次只重编改动文件。理解产物结构,才能和打包器/Node 运行时正确对接。

# 输出结构:src/a.ts → dist/a.js,目录一一对应 npx tsc # 读 tsconfig 的 outDir npx tsc --incremental # 生成 dist/tsconfig.tsbuildinfo npx tsc --watch # 监听改动,持续增量编译 # declaration: true 会同时产出 .d.ts(供别人当依赖时用)

论声明文件要随包发布

你写的库若想被别人以"有类型"的方式引用,编译时要开 declaration: true 产出 .d.ts,并在 package.json 的 types 字段指向它。否则别人 import 你的包又会回到"找不到声明"的老路——类型即契约,得发出去才算数。

5

与框架结合:React / Vue 里的 TS

React Generics · Type Guard · Vue <script setup>

TS 的真正价值在框架里兑现:组件 props 有类型、hooks 推断正确、Vue 的 ref 不丢类型。这一章讲"在 React/Vue 里把类型用对"的高频套路。

React 泛型组件与 props 类型

组件本质是个函数,所以泛型组件就是"带 <T> 的函数组件"——常用于列表、表单这类要保留数据类型的组件。

// 泛型列表组件:item 类型由调用方决定,渲染时仍带类型 function List<T>({ items, render }: { items: T[]; render: (item: T, i: number) => React.ReactNode; }) { return <>{items.map((it, i) => <div key={i}>{render(it, i)}</div>)}</>; } // 用:T 推断为 { id: number },render 里 it.id 有提示 <List items={users} render={u => u.id} />

类型守卫与可辨识联合

运行时判断类型要靠类型守卫——一个返回 x is T 的函数。配合"可辨识联合"(每个分支带一个字面量 type 字段),能在 switch 里自动收窄。

// 自定义类型守卫 function isError(r: Result): r is { ok: false; msg: string } { return r.ok === false; } if (isError(res)) { console.log(res.msg); } // 这里 res 收窄为错误分支 // 可辨识联合:用 type 字段做 switch 分发 type Action = { type: 'add'; n: number } | { type: 'reset' }; function reduce(a: Action) { switch (a.type) { case 'add': return a.n; // a.n 可用 } }

论为什么不用 as 强转

as 是"我比编译器更懂",用多了类型系统形同虚设,且一旦判断错了运行时才炸。is X 守卫让编译器跟着你的运行时检查一起收窄,既安全又可复用。能用守卫就别用 as。

Hooks 类型:useRef / useState / 自定义 hook

React hooks 的类型推断大多开箱即用,但 useRef 的"可空初始值"和自定义 hook 的返回类型常踩。

// useRef 初始为 null 时,显式给类型,否则推断成 RefObject<null> const el = useRef<HTMLInputElement>(null); el.current?.focus(); // current 可能为 null,需可选链 // 自定义 hook:返回元组,调用方解构后各自有类型 function useToggle(init = false): [boolean, () => void] { const [on, setOn] = useState(init); return [on, () => setOn(v => !v)]; }
useState 初始 undefined 会推断成 never-like

useState<User | null>(null) 必须显式给泛型,否则 TS 从 null 推不出 User,后面赋值 User 会报错。凡初始值是 null/undefined 且后续会变,一定显式标注泛型。

Vue 3 组合式 API + <script setup lang="ts">

Vue 3 的组合式 API 对 TS 友好。<script setup lang="ts"> 里用 ref/reactive,类型基本自动推断。

<!-- Vue SFC:类型从初始值推断 --> const count = ref(0); // Ref<number> const user = reactive({ name: '' }); // { name: string } // reactive 解构会丢响应性,改用 toRefs const { name } = toRefs(user);

defineProps / defineEmits 与类型

Vue 里用基于类型的 defineProps / defineEmits,直接传类型而非运行时选项,既简洁又能复用到父组件。

// 基于类型的 props(推荐):类型即文档,还能复用 defineProps<{ title: string; count?: number }>(); // emits 也用类型声明,父组件拿到正确的 payload 类型 const emit = defineEmits<{ (e: 'change', id: number): void; }>(); emit('change', 1); // 第二个参数被约束为 number

论两个框架的 TS 哲学差异

React 是"TS 原生"——组件就是函数,类型直接套;Vue 用编译器在 SFC 里做了类型推导,写法更紧凑但依赖工具链。无论哪个,把 props/emit/state 的类型写清楚,是组件可维护的前提。别因为"能跑"就留 any。

Vue 响应式类型的两个坑

Vue 的响应式包裹(ref/reactive)在类型上也有"代价",两个最常见的坑:解构丢响应性、ref 在模板外要多写 .value。

坑现象解法
reactive 解构解出的字段不再响应式用 toRefs
ref 在 JS 里要 .value 才能取值模板里自动解包,JS 里写 .value
ref 包裹对象多余一层(reactive 更合适)对象用 reactive
类型丢失未标注泛型推断成宽泛类型显式 ref<T>()

论框架的"响应式"和 TS 的"不可变"是两件事

Vue 的 ref/reactive 解决"数据变了视图自动更新"(运行时效能);TS 的 readonly/DeepReadonly 解决"编译期防止误改"(安全)。别混淆:加了 readonly 不代表它不响应式,加了 ref 也不代表它不可改。

6

编译与运行时:tsc、tsx、ESM/CJS 互操作、性能

tsc · tsx · ESM/CJS Interop · Build Perf

最关键也最常被误解的一点:TS 的类型在编译后会被完全擦除。运行时没有类型,报错只能发生在编译期。理解这点,才能搞清 tsc/tsx 的差异、ESM/CJS 互操作的坑、以及为什么大项目编译慢。

tsc 编译流程:从 .ts 到 .js

tsc 做两件事:类型检查 + 转译(去类型、降语法到 target)。类型信息本身不进产物——你写的 : string 在 .js 里消失得无影无踪。

# 只检查不输出(CI 类型门禁常用) npx tsc --noEmit --strict # 编译输出到 dist npx tsc -p tsconfig.json # 增量编译:只重编改动的文件(靠 tsbuildinfo) npx tsc --incremental

论为什么"类型错误"不会到运行时

因为类型在编译阶段就被擦掉了,运行时 JS 引擎根本不知道有类型。所以 TS 的保证是编译期的——一旦你用 any/as 绕过,错误就会溜到运行时才爆。这也是为什么 strict + 类型门禁值得投入。

tsx / ts-node:直接跑 TS 不显式编译

开发/脚本时不想先 tsc 再 node,用 tsx(基于 Esbuild,极快)或 ts-node 直接执行 .ts。它们边跑边转译,省一步。

# 直接跑,底层用 esbuild 即时转译,比 ts-node 快很多 npx tsx src/server.ts # ts-node 也能跑,但默认走 tsc 较慢,可加 --swc 加速 npx ts-node src/script.ts # 注意:tsx 不做完整类型检查,类型错误也能跑(只转译)
tsx 跑通 ≠ 类型正确

tsx/ts-node 只转译不严格检查,所以"能跑起来"不代表"类型没问题"。生产构建和 CI 必须单独跑 tsc --noEmit 把关。别把"本地 tsx 跑通"当成"类型安全"。

ESM / CJS 互操作:最常见的运行时炸点

Node 同时有 CommonJS(require)和 ES Module(import)。两者混用时要靠 module/moduleResolution 与包的 exports 协调,配错就 ERR_REQUIRE_ESM 或 default is not a function。

场景现象解法
CJS 包被 ESM import需 .default 才是真导出用 import pkg from 'x'
ESM 包被 CJS requireERR_REQUIRE_ESM改成 dynamic import()
扩展名省略Node 找不到模块NodeNext 写全 .js
// package.json:type 决定 .js 默认是哪种模块 { "type": "module" } // .js 视为 ESM { "type": "commonjs" } // .js 视为 CJS(默认) // CJS 里想用 ESM 包:用动态 import const mod = await import('some-esm-pkg');

类型擦除的真相:运行时没有 type

一个经典误区:以为 interface/type 会在运行时生成校验。不会——它们完全消失。要运行时校验(如接口入参),得用 Zod 这类"带运行时的 schema 库"。

// 编译后,下面的 : User 和 interface 全没了,运行时无校验 interface User { name: string; } function save(u: User) { /* ... */ } // 要运行时校验,用 Zod(schema 同时是类型来源) import { z } from 'zod'; const UserSchema = z.object({ name: z.string() }); UserSchema.parse(input); // 运行时真的校验,且推断类型

论"编译期类型"与"运行时校验"是两套

TS 管"我写的代码内部一致"(编译期);Zod/接口 schema 管"外部进来的数据可信"(运行时)。边界处(API 入参、表单、配置文件)必须用运行时校验补上 TS 擦除掉的真空。两者配合才是完整安全。

编译性能:project references 与更快的工具链

大项目 tsc 可能几十秒。解法:① project references 把仓库拆成子项目,只编改动的部分;② 用基于 SWC/Esbuild 的检查器(如 tsgo、typescript-go)把类型检查提速数倍;③ 编辑器用 tsc --watch 增量。

// 根 tsconfig 引用子项目,各子项目独立编译、互相缓存 { "files": [], "references": [ { "path": "./packages/core" }, { "path": "./packages/app" } ] } // 配合 turbo 之类只重建受影响子项目
skippingLibCheck 是什么、何时开

默认 tsc 会检查所有 .d.ts(含第三方),大项目很慢。skipLibCheck: true 跳过对声明文件的检查,通常安全且大幅提速——绝大多数项目该开。但别用它掩盖"你自己写的错误 .d.ts"。

typesVersions 与多版本声明分发

一个包可能要同时支持不同 TS/运行时的类型。用 package.json 的 typesVersions 字段,按"导入的路径前缀"指向不同 .d.ts,实现"同一包多套类型"。

机制作用何时用
exports + types按入口给出对应声明常规分包类型
typesVersions按导入路径前缀分流同入口多 TS 版本
/// <reference>合并多份声明环境声明聚合

论类型分发的本质是"让调用方无感"

无论 exports 还是 typesVersions,目标都是:调用方写一句 import,就能自动拿到"适配他环境的正确类型"。库作者把复杂度藏在配置里,用户体验才顺。这也是"类型即契约"在分发层面的体现。

类型驱动开发的取舍:何时该上 TS

TS 不是万能银弹。它的收益随"项目复杂度、协作人数、生命周期"上升而显著;小脚本、一次性原型则不划算。下面是经验判断。

场景建议理由
大型长期项目 / 多人协作TS 必上类型即文档、重构安全
库 / 框架 / 工具链TS 必上消费者靠类型用对 API
中小业务应用推荐上strict 早发现问题
一次性脚本 / 原型可不上类型成本 > 收益

论上 TS 的"隐性成本"要算清

TS 带来:类型标注时间、第三方类型缺失时要补 .d.ts、复杂泛型会拖慢编译与招聘门槛。但它省下的是"运行时才发现的低级 bug"和"改一处牵一身的恐惧"。权衡的结论对绝大多数生产项目是正向的——只是别在写一次性 demo 时也硬套。

结
本页要点

TypeScript 的价值在"编译期发现错误、类型即文档、重构安全"。基础是类型推断、字面量类型(as const)、联合/交叉与收窄(typeof/instanceof/in/可辨识联合)、泛型(保留类型信息而非退化成 any)。类型体操用条件类型、infer 提取、映射类型(keyof 遍历)、递归类型、模板字面量类型,业务够用即可、留给库作者。装饰器(TC39 标准 vs experimentalDecorators)适合横切关注点(日志/缓存/鉴权/DI),配 reflect-metadata 做依赖注入。工程上用 tsconfig 的 strict 逼出隐患、paths/moduleResolution 决定模块解析、.d.ts 给无类型 JS 补说明书、优先装 @types 而非 @ts-ignore。框架里 React 用泛型组件+类型守卫+显式 hook 泛型,Vue 用 script setup + defineProps/defineEmits 类型声明。运行时真相:类型在编译后被擦除,tsx 只转译不检查,ESM/CJS 互操作靠 module/exports 协调,运行时校验用 Zod 补齐;大项目用 project references + skipLibCheck 提速。

自测 · 看你是否真懂

1.为什么说 any 是"债务"?unknown 和它有什么区别?泛型又为什么比 any 好?

查看答案

any 关闭类型检查且会"传染"相邻表达式,让代码重新变不安全。unknown 同样表示"未知",但使用前必须显式收窄(typeof/instanceof),更安全。泛型在调用点把具体类型接住并保留(如 first<string> 返回 string),类型信息不丢且仍带检查——要"通吃"又不想"失明"用泛型。

2.条件类型的"分发"是什么?为什么有时要用 [T] extends [U] 关掉它?

查看答案

裸类型参数(T extends U)遇到联合类型会"逐成员计算",得到分布的联合结果(如 ToArray<string|number> → string[]|number[])。把 T 包进元组 [T] extends [U] 会关掉分发,当作整体判断。想逐成员变换用裸写,想整体判断加方括号。

3.TS 编译后类型去哪了?为什么"tsx 跑通"不等于"类型正确"?

查看答案

interface/type/注解在编译后被完全擦除,运行时 JS 引擎不知道有类型——TS 的保证只在编译期。tsx/ts-node 只做即时转译、不做完整类型检查,所以能跑起来也可能有类型错误。生产/CI 必须单独跑 tsc --noEmit 把关。

4.TC39 标准装饰器和旧的 experimentalDecorators 为什么不能混用?依赖注入靠什么在运行时拿到类型?

查看答案

两者语法与实现机制不同(签名、元数据来源),同项目混开会编译冲突或行为诡异,老项目保持旧模式、新项目用标准装饰器。运行时依赖注入靠 reflect-metadata:emitDecoratorMetadata 把构造参数类型写进元数据,容器据此自动 new 并注入依赖。

下一步往哪走

路学完 TypeScript 之后

① 串前端:回前端补全页看 React/Vue 怎么用 TS(泛型组件、类型守卫、defineProps),以及类型安全在测试/状态管理里的延伸。

② 看 Node 进阶页:NestJS 大量用装饰器 + 依赖注入 + 严格 tsconfig,是 TS 工程化的旗舰场景。

③ 端到端类型:了解 tRPC / OpenAPI 代码生成(如 openapi-typescript)/ Zod,把前后端类型真正打通,让"接口契约"在编译期可校验。

④ 动手:给一个 JS 项目开 strict 模式,补 .d.ts,接 Zod 做入参校验,体会"编译期+运行时"双层安全。