老李开了一家快递仓库,十几年都是“装车就走”的粗放模式。直到有一次,货车开到客户楼下,卸下来才发现本该送冰箱的车里装的是洗衣机——退货、赔钱、赔信誉。问题出在哪?出在“发出去之前没人检查”。老李痛下决心,在每条出库流水线上加装一个“类型检查站”:每件货物装箱时必须先贴好标签,纸箱装的是文本,电器装的是数字,开关装的是真假;检查站的师傅把标签和实物一一核对,对的放行,错的当场拦下。 这个故事讲的就是 TypeScript。它并不是一门全新的语言,而是在 JavaScript 这座“老仓库”上加装了一套类型检查体系。JavaScript 的变量要等程序真正跑起来才知道是什么货,错货坏货全都在客户收货时才暴露;TypeScript 则在代码变成 JavaScript 之前,就像检查站的师傅一样把货物逐件核验,把最常见的一类错误拦在“发货”之前。接下来,我们就用这座仓库的升级故事,认识标签(基础类型)、二选一通道(联合类型)、组合规格(交叉类型)、给规格起名(类型别名)、签规格合同(interface)、老师傅的直觉(类型推导),以及如何让老仓库一层层装上新检查站(渐进式迁移)。
类型系统是前端工程化的地基:掌握它,你就能在代码提交前用编译器替代肉眼排查,把低级 bug 挡在门外——这是每一位 AI 应用前端开发者必备的工程素养。
可视化演示
展示从JavaScript到TypeScript的渐进迁移路径,以及TS的六大类型核心概念:Basic(基本类型)、Union(联合)、Inter(交叉)、Type(别名)、Interf(接口)、Infer(推导)。
本节配有交互动效(进入视口后自动加载,约 3KB)。
TypeScript类型系统 Basic(str/num/bool)、Union(A|B)、Inter(A&B)三大基础类型组合,Type(别名)、Interf(接口)、Infer(推导)三大高级特性 type T = A | B; interface I { x: number } 渐进式迁移:JS代码直接改.ts后缀即可,编译时(Compile)捕获错误替代运行时暴露
类型系统概览
老李吃了发错货的亏,回来后第一件事就是找老师傅商量。老师傅说:“以前仓库小,货在眼前,靠眼睛盯就够;现在每天上千件货,谁还记得住哪个箱子装的是什么?”JavaScript 项目也一样:变量类型完全自由,一个人写的时候没问题,团队协作、项目变大之后,age 到底是数字还是字符串,只有程序跑起来的瞬间才知道——这就是“动态类型”的隐患。
TypeScript 的解法,是给 JavaScript 加一套“静态类型检查”。所谓“类型”,就是“这个值是什么货”;静态,就是“发货前定下来并核验一遍”。于是有了 let age: number = 18 这样的写法:: number 就是给变量贴上“数字类”标签,检查站师傅在编译期逐一核对,标签与实物不符就直接拦下。
需要强调的是,这套检查并不改变程序的运行方式。TypeScript 编译器完成核验后,产出的仍然是纯 JavaScript,老仓库的货车、公路、送货流程一条不变。
装上了检查站,仓库还是那个仓库,货车路线、配送时效都没变,只是从此“错货”在出库门前就被拦下。理解了这个定位,后面所有类型知识,都只是检查站里一张张具体的“标签规则”。
- JavaScript 部分这是你早已掌握的那门语言,语法、运行逻辑、生态全部原样保留,之前的代码一行都不用丢弃。
- Type System 部分额外叠加的静态类型规则,相当于仓库里新增的“检查站”,专门核验发货前的标签与实物。
- 加号组合加号表示“组合”而非“替换”;检查只在编译期生效,最终交付的仍然是可以直接运行的 JS。
基础类型
检查站上班的第一天,师傅们先要认识“货品类别”——也就是贴纸。老李的仓库一共有几种最常用的标签:文本类的 string,电器类的 number,开关类的 boolean,还有表示“空包裹”的 null 和 undefined,以及装着杂七杂八东西的 object 和 array。这就是 TypeScript 的基础类型。
写法上非常直观,声明变量时在冒号后写上类型即可:
数组和对象也有自己的形状:number[] 表示“一排数字”;[number, string] 这种元组表示“第一个位置必须装数字、第二个位置必须装文本”的固定顺序货包。对象则用花括号描述“箱子里必须有哪些隔层”。
基础标签就是检查站的地基。有了这些贴纸,师傅们才能谈“二选一”和“全都要”——也就是后面要认识的联合类型与交叉类型。
- T ∈ 集合任何变量的类型 T,都必须属于这个“类型标签库”中的某一类,就像每件货物都要从标签册里选一张贴纸。
- string / number / boolean三个最常用的原始类型:文本用 string,数字用 number,开关状态用 boolean;这是检查站认货的第一批专业贴纸。
- null / undefined表示“空包裹”的两种特殊标签,null 常表示刻意留空,undefined 表示还没装入任何东西。
- object 与 array复合类型标签,对应“一箱混装货”和“按次序排列的一排货”,它们的内部结构还能继续细化约束。
let name: string = "tom";let age: number = 18;let isOk: boolean = true;
联合类型
新仓库上线后,发现一张发货单可能是客户在手机上下的电子单,也可能是传真来的纸质单——检查站得认两种格式。这个“要么 A 要么 B”的通道,就是联合类型,写法是用竖线 |:
联合类型的核验规则是“二选一放行”:标签与实物匹配其中任何一类就算过关。但问题来了——既然可能是 A 也可能是 B,检查站想知道“这张单到底是哪种”,就得现场判断。在 TS 里这叫“类型收窄”:
实战中还常用“可辨识联合”:给每种货物加一个特殊字面量标签,比如 { kind: "order" } 与 { kind: "invoice" },检查站靠 kind 一眼分流;而 string | null 这种“空值兜底”则时刻提醒你:这个值可能是空包裹,必须处理。
联合类型就像仓库的“多规格通道”:一车货可以走 A 门或走 B 门,但门卫只有确认你走的是哪扇门,才放行该门专属的卸货设备。
- A 与 B 两个候选类联合类型把一条通道拆成两条候选通道,货物只要匹配其中任意一条就能通过检查站。
- ∪ 并集符号并集读作“或”,表示二选一;它和交叉类型里的“且”(∩)方向完全相反,是最容易混淆的一对符号。
- 使用限制未收窄前,TS 只允许调用两边都支持的通用操作;要调用某一边专属的方法,必须先 typeof 收窄。
let id: string | number = "abc-123";id = 99; // 同样合法
if (typeof id === "string") { id.toUpperCase(); // 现在确认是 string, 可以放心调用字符串方法}
交叉类型
有些高端客户对包装有硬性要求:“既要防水,又要防火”——两种规格必须同时满足,缺一样都拒收。这种“全都要”的组合规格,就是交叉类型,用 & 表示:
交叉类型会把两边的“属性条款”合并成一个更大的形状:防水层要有,防火层也要有,组合成一个“既防水又防火”的包装箱。检查站核验时,每一项要求都要核对。
它和联合类型是仓库里最容易被拿反的两张规则:A | B 是“二选一挑一个用”,A & B 是“合在一起全部都要”。还有一个极端情况:如果两个规格互相矛盾,比如 string & number——要一个“既是文本又是数字”的箱子,根本做不出来,结果就是空类型 never。
交叉类型就是检查站里的“组合规格单”:要求每一条都打勾,少一层防火材料,箱子就不合格,直接退回。
- ∩ 交集符号从集合论看是交集,但在类型系统里它表示“规格叠加”——两个规格的要求全部都满足。
- 属性合并当 A 和 B 都是对象类型时,交叉结果会合并两边的属性,形成一个更大的对象形状。
- 冲突变成 never若两边要求互相矛盾(如 string & number),任何值都无法同时满足,结果就是空类型 never。
type A = { x: number };type B = { y: string };type C = A & B; // C 的值必须同时有 x 和 y
类型别名
发货单的规格组合越来越多,每次都在货单上写“文本或数字,或由系统生成的编号”太啰嗦。老师傅出了个主意:给常用规格起个名字,以后货单上只写名字,检查站翻规格本就能核验。这就是类型别名,用 type 关键字:
要点是:类型别名不创造新类型,它只是“老李”和“李老板”的关系——同一个人的两种称呼。检查站看到 UserId 时,会翻到规格册对应页面,按 string | number 的实际内容去核验。
别名的灵活性很高:可以指代联合、交叉、元组、对象等任何类型组合,还支持泛型参数成为“可定制尺寸的通用包装箱”:type Wrap<T> = { value: T }; 其中 T 由使用时现场指定。
别名让检查站的标签册从“几十种基础贴纸”扩展出“上千种组合规格”。规格再复杂,只要名字起得清楚,仓库的规则就能一直保持可维护。
- type 关键字它告诉编译器:“我在规格册上登记一个新名字,之后可以随时引用它”,这就是起别名的仪式。
- 别名 UserId别名本身不产生新类型,它只是一个“称呼”,检查站看到它时,会翻到规格册去核验右侧的真实内容。
- 等号右侧右侧可以是联合、交叉、元组或对象等任意类型组合,这是别名最灵活的地方。
type UserId = string | number;type Point = { x: number; y: number }; let uid: UserId = 42;let p: Point = { x: 1, y: 2 };
接口 interface
老李发现,和供应商长期合作不能只靠口头约定,得签合同:每批货必须符合合同条款,条款写什么,到货就核验什么。TypeScript 的 interface 就是这份“对象形状合同”:
合同中冒号是“必备条款”:任何自称 User 的对象,必须有 name 且它是 string。age? 是“可选条款”:可以没有;如果有,则必须是 number。检查站收货时,只核对合同上列出的条款。
接口之间还能“继承”——在主合同后面补签补充协议:interface Admin extends User { permissions: string[] }。管理员用户除了普通用户的条款,还多一条权限数组。另一个特点是“声明合并”:同名的 interface 会自动拼成一份更大的合同,就像两份补充协议拼在一起生效。
至于和 type 的区别:描述对象形状时两者都能用,社区惯例是“对象优先用 interface,遇到联合、交叉、元组时用 type”。这个边界,我们到误区部分再展开。
接口就是检查站与供应商之间那本“合作规格书”。合同条款越清晰,到货核验越顺畅,双方扯皮越少——放在代码里,就是前后端联调时的心照不宣。
- interface 关键字它用来声明一份“对象形状合同”,规定了对象必须具备哪些属性、各属性是什么类型。
- 必备条款 namestring:冒号表示“必须有且类型要匹配”,任何自称 User 的对象,没有 name 或 name 不是 string,检查站直接拒收。
- 可选条款 age?问号表示“可以没有;如果有,则必须是 number”,这是接口对“弹性要求”的表达方式。
- extends 扩展接口可以继承另一个接口,在原有条款基础上追加新条款,就像在主合同后面补签补充协议。
interface User { name: string; age?: number;}
类型推导
检查站的老师傅干了二十年,有个本事:瞟一眼箱子外形,就知道里面大概是什么货——根本不用等贴标签。TypeScript 也有这种“直觉”,叫类型推导:
推导的规则很朴素:从“初始值”反推变量类型,从“返回值表达式”反推函数返回类型,甚至能借助“上下文”判断——比如事件回调里的事件对象,TS 会根据你绑定的元素自动补全类型。这解释了为什么很多 TS 代码看起来“没写类型,但处处有类型”。
但老师的直觉也有边界:函数参数没有初始值,推导无从下手,只能靠显式标注;复杂嵌套的对象结构,推导结果可能不够精确,也需要标注。最佳实践是“能推导的少写,边界处写清楚”——把类型注解留给函数参数、返回值这类“检查站看不到线索”的位置。
类型推导不是“从此不用写类型”,而是“减少你不得不写的类型”。老师傅认得出的货不必贴标签,但碰上没见过的新式货物,还是要老老实实把规则写下来。
- let x = 42 初始值变量声明时带着初始值,这个值就是检查站判断类型的“现场线索”。
- ⇒ 推导箭头箭头表示编译器沿着值反向推断类型——看见数字 42,就断定 x 是 number,不需要手动标注。
- xnumber 结论:推导出的类型会写进编译器的“类型环境”,之后任何给 x 赋 string 的行为都会在编译期被拦截。
let age = 18; // age 被推导为 numberlet name = "tom"; // name 被推导为 string
渐进式迁移
老李的仓库不可能一夜之间全部翻新。他的做法是:先挑一个新分拣区试点,装上检查站跑顺,再逐步铺到老分区,最后把整个仓库改造完成。JavaScript 迁移到 TypeScript 也是同样的节奏。
第一步,开通 allowJs,允许 .js 老文件和 .ts 新文件混在一个项目里,新代码开始用 TS 写,老代码原地不动。第二步,开启 checkJs,让编译器开始对 .js 文件也做检查,老文件的隐患逐渐浮出水面。第三步是 strict:从关闭的“宽松检查”逐步收紧到“全面严格”,让团队先适应再下猛药。第四步,给暂时没时间改的老代码标上 any——一张“临时通行证”,先放行再回头补标签,最终的目标是把 any 一个个换回精确类型。
迁移的优先级也有讲究:先给公共数据模型、工具函数补类型,收益最高;业务代码可以缓一缓。TypeScript 编译器会自己列出一份“哪里还缺类型”的检查清单,按着清单推进,就像按着检查站的拦截记录逐项整改。
老仓库改造完成那天,每一件出库货物都贴上了标签,每一张发货单都经过检查站。发货前的错误少了,投诉电话也少了——TypeScript 给工程带来的,正是这种“把问题拦在发货前”的踏实感。
- strict 配置项这是 tsconfig.json 里的总开关,控制整个项目的类型检查严格程度,相当于决定检查站的工作强度。
- false → true 迁移路径迁移不是一步到位,而是先关闭严格模式让老代码通过,等新代码稳定后再逐步打开。
- boolean 类型它只有“开”和“关”两个取值,但开启的过程中可以配合 any、allowJs、checkJs 做阶段式过渡。
AI 应用场景
大模型响应的安全解析
阶段:数据解析与校验(AI 应用的输入输出管道)
解决的问题:大模型返回的 JSON 结构不稳定,字段可能缺失或类型漂移。传统 JS 直接 res.data.items.map(...) 取值,一旦结构不对就在运行时崩溃,用户看到的是一张白屏。用 TypeScript 先把响应形状定义成 interface,再用联合类型处理“成功/失败”分支,类型错误在编译期就会暴露。
具体案例:一个聊天机器人前端,定义 type ChatMsg = { role: "assistant" | "user"; content: string } | { role: "system"; notice: string },用可辨识联合按 role 分流渲染。即使模型返回了意料之外的字段结构,编译期就能发现类型不符,而不是等到线上崩溃。
前后端接口对齐的自动报警
阶段:模型服务接口开发与联调
解决的问题:推荐系统的后端把 userId 从 number 改成 string,前端靠肉眼几乎不可能在每个调用点发现。用 interface 定义 API 响应合同,后端字段一旦变化,前端编译结果立刻标红,检查站代替人工排查。
具体案例:某推荐系统上线前,后端把订单金额字段从 price 重命名为 amount 并改成字符串。前端所有引用 Order["price"] 的地方在构建时全部报错,开发按错误清单批量修正,联调阶段零事故。
多端 AI 产品的共用类型约定
阶段:多端协作开发(小程序 / Web / Node 服务)
解决的问题:同一个 AI 产品在 Web、小程序、服务端三端都要处理相同的数据结构,各写各的解析逻辑极易漂移。把数据结构收拢成一份 type/interface 定义,作为三端共用的“规格合同”,任何一端修改都触发全链路编译检查。
具体案例:一个 AI 绘画产品的三端代码库,公共类型包定义了任务状态 type TaskState = "pending" | "running" | "done" | "failed",三端共同引用。后端新增状态时,前端 switch 分支漏写直接编译报错,避免了发布后“状态展示空白”的线上事故。
Python 演示
import sysfrom typing import Any, Dict, List class SimpleTypeChecker: """一个迷你类型检查站: 模仿 TypeScript 在运行前核对变量类型。""" def __init__(self) -> None: self.type_env: Dict[str, type] = {} self.errors: List[str] = [] def infer(self, value: Any) -> type: """类型推导: 像老师傅那样根据值直接判断出类型。""" return type(value) def declare(self, name: str, value: Any) -> None: """声明变量: 不写标签, 由初始值推导出类型。""" self.type_env[name] = self.infer(value) print(f"[OK] 声明 {name}: {self.infer(value).__name__}") def assign(self, name: str, value: Any) -> None: """赋值检查: 新值必须与已推导的类型一致, 否则拦截。""" if name not in self.type_env: self.errors.append("未知变量 " + name) return expect: type = self.type_env[name] actual: type = self.infer(value) if expect != actual: self.errors.append( f"[拦截] {name} 期望 {expect.__name__}, 实际 {actual.__name__}" ) else: print(f"[OK] {name} 更新为 {value!r}") def report(self) -> None: """输出检查站拦截清单。""" if not self.errors: print(">>> 全部通过, 放行!") return print(">>> 检查站拦截清单:") for err in self.errors: print(err) if __name__ == "__main__": checker = SimpleTypeChecker() checker.declare("age", 18) checker.declare("name", "tom") checker.assign("age", 19) checker.assign("name", "bob") checker.assign("age", "十九") checker.assign("age", None) checker.report()
self.type_env: Dict[str, type] = {}一张“变量 → 类型”登记表,相当于检查站的台账,记录每个变量当前的类型。def infer(self, value: Any) -> type:定义“老师傅直觉”:输入一个值,返回它的类型,对应 TS 的类型推导。self.type_env[name] = self.infer(value)声明变量时自动推导类型并登记,对应let age = 18被推导为 number。if expect != actual:新值与登记类型不一致时记入拦截清单,对应 TS 编译期的类型报错。checker.assign("age", "十九")用字符串给 number 型变量赋值,被检查站拦截并记录错误。checker.report()输出本次“编译期检查”的完整结果,对应 TS 编译器打印的错误列表。
常见误区
误区我用了 TypeScript,类型检查全部通过,程序运行时肯定不会再有 bug 了。
正解类型系统只抓“类型错误”,不抓业务逻辑错误。检查站只负责核验“标签和实物是不是一类货”,不保证客户真的喜欢这批货。任何、类型断言都能绕过检查,逻辑写错、接口返回异常、并发问题依然会在运行时出现。TS 的价值是把“一类常见错误”提前拦住,而不是担保程序绝对正确。
为何常见:把“静态检查”误当成“正确性保证”,把类型系统的作用范围想得太宽了。
误区`let x: string | number` 声明之后,x 既能调用字符串的方法,又能调用数字的方法,随便用。
正解联合类型是“或”不是“且”,x 在任意时刻只能是其中一种。检查站只知道“可能是文本也可能是数字”,所以在收窄之前,只允许调用两种类型都支持的通用操作;想用 toUpperCase() 这种字符串专属方法,必须先 typeof 收窄,确认它此刻确实是 string。
为何常见:日常语言里“或者”有时带着“兼有”的模糊感,初学者容易把“|“理解成”和“,而”和”其实是交叉类型的语义。
误区`A & B` 比 A 和 B 都厉害,它包含了 A 或 B 中任意一个的所有能力,随便挑着用。
正解交叉类型不是“挑大的用”,而是“全都要”:对象必须同时具备 A 和 B 的所有属性,少一个都不合格。string & number 这种互相冲突的组合,结果不是“更大”而是“空”——never,没有任何值能满足它,就像做不出“既是液体又是固体”的箱子。
为何常见:用联合类型的“或”直觉去套交叉类型,把“∪“和”∩”的语义彻底搞反了。
误区反正都是描述类型的语法,interface 和 type 没有任何区别,选哪个全看心情。
正解两者有明确分工:type 是“万能的别名”,能表达联合、交叉、元组、映射等更广的结构;interface 是“对象合同”,支持声明合并(同名自动合并)、可以被 extends 继承,在公共 API 库中更受青睐。团队惯例通常是“描述对象形状优先 interface,需要联合/交叉/元组时用 type”。
为何常见:在小项目里两者表现几乎一样,很多人没碰到“声明合并”“元组类型”这类差异边界,就误以为永远等价。
误区TS 会自动推导类型,那我就什么都不标,让编译器自己猜,代码最简洁。
正解推导的前提是“有线索”。函数参数没有初始值、复杂嵌套结构推导结果可能不够精确、跨模块的公共边界需要稳定的显式契约——这些位置不标注,类型就退化成隐式 any,检查站形同虚设。正确的姿态是“能推导的地方少写,边界处写清楚”,把注解留给参数、返回值和对外接口。
为何常见:体验过 let age = 18 被自动推导成 number 之后,容易高估推导能力,忽略了“没有线索时推导无能为力”的前提。
面试题
Q1. TypeScript 给 JavaScript 带来的最核心能力是什么?
- A.让代码运行速度更快
- B.编译期静态类型检查← 正确答案
- C.自动完成垃圾回收
- D.提供全新浏览器接口
答案:B。类型系统真正的价值是在编译期(发货前)把类型错误拦下来,就像检查站核验货物标签。A 与运行时性能无关,C 是 JS 引擎自带能力,D 与类型检查无关。
Q2. 下面哪种写法能正确声明一个 number 类型的变量 age?
- A.let age: number = 25← 正确答案
- B.let number: age = 25
- C.let age = number 25
- D.var age := 25 number
答案:A。: number 是类型注解,相当于给变量贴上“数字类”标签。B 把关键字和变量名写反了,C、D 语法不合法,检查站根本读不懂。
Q3. 联合类型 let id: string | number 允许给 id 赋什么值?
- A.只能是字符串类型
- B.只能是数字类型
- C.字符串或数字皆可← 正确答案
- D.同时是两者类型
答案:C。竖线表示“或”,是二选一通道,C 对。A、B 只看到一边;D 把“或”理解成“且”,那其实是交叉类型的语义,最容易混淆。
Q4. 交叉类型 type T = A & B 得到的类型有什么特点?
- A.要么是 A 要么是 B
- B.同时拥有两者全部属性← 正确答案
- C.只保留 A 的属性部分
- D.自动生成全新父类型
答案:B。& 表示“全都要”,像既防水又防火的箱子,两边规格都必须满足,B 对。A 是联合类型语义,C、D 是常见误解。
Q5. 关于 type 与 interface 的区别,下列说法正确的有?
- A.type 可以定义联合类型← 正确答案
- B.interface 支持声明合并← 正确答案
- C.type 可以给交叉类型起名← 正确答案
- D.interface 可以表示元组类型
答案:ABC。type 是“万能的别名”,联合、交叉、元组都能表达,A、C 对;interface 是“对象合同”,同名声明会自动合并,B 对。元组只能用 type 表示,D 错。
Q6. 在 let x = 42 这句代码里,TypeScript 推导出 x 是什么类型?
- A.自动推导为 number← 正确答案
- B.自动推导为 string
- C.自动推导为 any 类型
- D.编译器会直接报错
答案:A。推导就是老师的直觉:看到初始值 42 就知道是数字货,A 对。只有完全找不到线索时才会退化成 any。
Q7. 接口中 age?: number 表示什么意思?
- A.该属性必须存在且为数字
- B.该属性可以有也可以没有← 正确答案
- C.该属性只能是 undefined
- D.该属性只能是 null 类型
答案:B。问号是“可选条款”的标记:合同允许缺这一项,若有则必须是数字,B 对。A 是必选属性,C、D 把可选与空值混为一谈。
Q8. 迁移老代码时给变量标注 any 类型,会发生什么?
- A.跳过该变量的类型检查← 正确答案
- B.强制所有值变成字符串
- C.运行时必定抛出错误
- D.自动推导出精确类型
答案:A。any 是“临时通行证”,盖上它检查站直接放行,A 对。它用来平滑迁移,最终要逐步换回精确类型,而不是长期保留。
Q9. 下列属于 TypeScript“类型收窄”手段的有?
- A.使用 typeof 判断分支← 正确答案
- B.使用 in 检查对象属性← 正确答案
- C.判断值是否为 null← 正确答案
- D.使用 as 强制指定类型
答案:ABC。收窄是用条件判断把“二选一”变成“确定是哪一个”,typeof、in、空值判断都是常见方式,A、B、C 对。as 是“断言”,是强行盖章,不是依靠条件判断收窄,D 错。
Q10. 严格模式下,function f(n: number){} 传入 undefined 会怎样?
- A.编译期报错提示类型不符← 正确答案
- B.运行期自动转为字符串
- C.编译期自动补默认值
- D.不会做任何检查提示
答案:A。严格模式下 null/undefined 不能流入 number 参数,检查站在发货前就会拦截,A 对。这正是“编译期检查”与“运行时才知道”的差别。