TypeScript核心语法速览:从类型收窄到泛型约束的实战指南

📅 发布时间:2026/9/10 5:38:57
TypeScript核心语法速览:从类型收窄到泛型约束的实战指南
最近面试时我发现一个挺扎心的现象前端候选人的简历上几乎清一色写着“熟悉TypeScript”但真聊到类型收窄、泛型约束、装饰器这些具体语法时很多人只会说“我用过一点”“当JS写”。TypeScript 已经成了前端开发的默认选项但真正能把它用出价值的人远没有想象中多。这篇文章就是一份可以直接照着上手的 TypeScript 核心语法速览不讲虚的直接把最常用的类型、接口、泛型、类、配置和坑位过一遍用最贴近实战的方式帮你把这块短板补上。写这篇的初衷很实际。我见过太多人要么用any一把梭要么把 TypeScript 当成“给变量加类型的 JS”来写。前者丧失了类型系统的保护意义后者则完全没发挥出这套工具的真实威力。无论你是在准备前端面试还是刚从 JS 转向 TS又或者是已经在项目里用了一段时间却总是踩坑这篇文章都能让你在半小时到一小时内把核心语法串起来然后立刻用到代码里去。1. TypeScript 到底改了什么从“字符串拼写”到“代码即文档”1.1 没有类型系统的 JavaScript到底痛在哪先说一个很多人没有意识到的点JavaScript 不是一门“没有类型”的语言而是一门“运行时才检查类型”的语言。数组、函数、对象、字符串、数字这些类型在运行时真实存在只是你在写代码的时候不强制声明JS 引擎也不在编译阶段帮你兜底。于是问题就来了。一个接口返回了user.name后端临时把字段改成了nickname前端代码运行到这一行才抛undefined一个函数传参时把count当成了数字去1结果传进来的是个字符串拼成了101一个对象在多处被修改中间有人多塞了个字段别处取值时类型就悄无声息地漂移了。这些场景我猜每个前端都遇到过。TypeScript 解决的就是这个问题。它不是一门全新的语言而是 JavaScript 的超集。所有 JS 语法在 TS 里都合法你只需要把你“本来心里就想好的类型”明确地写出来TS 编译器就会在代码运行之前做一次静态检查。用一句玩笑话说TS 是把 JS 开发者“脑子里有、手上没写”的类型约束落到代码层面。1.2 编译时约束带来的连锁反应类型检查发生在编译阶段这个特性带来的连锁反应非常深远。首先是重构安全。以前你在一个大型项目里重命名某个公共组件的方法只能全局搜索替换然后满屏报错慢慢修。有了 TS你重命名一个接口属性所有涉及到这个属性的地方编译器都会帮你标红。我用vs code的重命名符号功能时经常感叹这种安全感是纯 JS 时代完全没有的。其次是代码即文档。我以前团队招了个新人看老项目里的 JS 代码时最常用的操作是“全局搜索这个变量在哪里被赋值”费时费力。TS 项目就舒服得多鼠标悬停能直接看到类型定义跳转定义一键到达接口的type或interface本身就是一份不需要额外维护的文档。第三是智能补全。类型声明完整之后VSCode 等编辑器的自动补全和信息提示会准确得多。方法有哪些参数、对象有哪些属性、函数返回什么类型统统不用记编辑器会提示你。这也是为什么很多前端老手用惯了 TS 之后很难再退回纯 JS 开发的原因——生产效率差距实在太明显了。1.3 这篇速览怎么用我把后面每一章都设计成“可以直接对照代码写”的模式先讲清楚几个核心概念然后给一个贴近业务的例子再用我实际开发中踩过的坑做补充。如果你时间紧张可以直接跳到第 2 章开始过语法第 6 章的工具链部分留到真正建项目时再看。2. 类型根基打牢变量声明、类型注解与最简单的两种类型2.1 变量声明let 和 const 的差异比你以为的更大TS 中声明变量沿用了 ES6 的let和const但叠加上类型系统之后你会注意到一个细节const声明的变量在 TS 中被赋予的是字面量类型而let声明的变量会被推断成更宽泛的基础类型。const theme dark; // 类型为 dark 这个字面量类型 let lang zh-CN; // 类型为 string这个差异在手动标注类型时非常有用。比如你想定义一组固定的选项type Theme dark | light | system; const DEFAULT_THEME: Theme dark; // 编译器会校验值是否合法如果哪天有人不小心把括号里的值写成了blur编译时会直接亮红牌。这种字面量联合类型在前端开发里极其常用尤其是表单状态、主题切换、按钮类型这些场景。2.2 基础类型注解别把所有东西都标成any基础类型的注解写法很简单但有一个问题很多人会犯为了图省事遇到不确定的类型就标any。你可能会说“反正现在能跑就行”但我要负责任地告诉你any会直接断开类型检查的链路。你写了一个any这块数据相关的所有类型保护都没了后面变量被赋成什么样、传给谁编译器统统不查了。真正应该用的未知类型是unknown。unknown表示“我也不知道它是什么但它是安全的”需要经过类型收窄之后才能使用不会偷偷把类型污染到别处。// ❌ 不推荐 function parseData(data: any) { return data.list.map((item: any) item.name); } // ✅ 推荐 function parseData(data: unknown) { if (typeof data string) { return JSON.parse(data); } if (Array.isArray(data)) { return data.filter((item) typeof item object); } return []; }基础类型string、number、boolean、null、undefined、object、symbol的注解方式都直接写英文单词就可以记住null和undefined在很多配置下要显式开启严格模式才会被严格检查。2.3 数组和元组在数据序列里做好约束数组的注解有两种写法string[]和Arraystring效果完全一样。实际项目里我更常用string[]这种写法更直观。let tags: string[] [ts, 前端]; let ids: number[] [1, 2, 3];元组是 TS 独有的类型用来表示“长度和每一项类型都固定的数组”。这个类型在接口返回固定格式数据时很有用比如坐标点[x, y]type Point [number, number]; const origin: Point [0, 0]; // 如果用数组写会失去对长度和位置的约束元组还有带标签的写法可读性更强比如[x: number, y: number]这样在使用时编辑器提示会非常清晰。2.4 类型推断不写注解不代表没有类型TS 还有一种能力叫类型推断也就是说你声明时不写注解编译器会从初始值推出来。这也是很多新手的迷惑点为什么我不能直接给let a 1; a text而必须写成let a: number | string 1。因为 TS 会根据初始化值推断a的类型为number之后你赋一个字符串编译期就觉得你把类型搞错了。所以一个最基础的经验是尽量让类型推断自己工作只在函数参数、返回值、复杂结构等推断不出来或推断不准确的地方手动标注。3. 联合类型与类型收窄这是面试和实战都绕不开的核心关3.1 联合类型把可能性列清楚联合类型是 TS 里非常高频的用法刚才提到的Theme dark | light就是字面量联合而string | number这种则是把不同的基础类型组合在一起。function formatId(id: string | number) { return ID:${id}; }这个函数接收字符串或数字都行。这里要注意如果你直接写id.toUpperCase()编译器会报错因为id可能是数字没有toUpperCase方法。想调用类型特有方法必须先做类型收窄。3.2 四种类型收窄方式把它们用熟类型收窄Type Narrowing是 TS 的核心能力也是我的面试必问题之一。本质上编译器会随着代码的执行路径把联合类型逐步缩小为更具体的类型。常用的收窄方式有四种。第一种是typeof。适合判断string、number、boolean、symbol、bigint、undefined、function这些基本类型。function print(msg: string | number | boolean) { if (typeof msg string) { console.log(msg.toUpperCase()); } else if (typeof msg number) { console.log(msg.toFixed(2)); } else { console.log(msg ? true : false); } }第二种是in运算符。适合判断对象上是否存在某个属性常用来区分两个结构不同的接口。type Fish { swim: () void }; type Bird { fly: () void }; function move(animal: Fish | Bird) { if (swim in animal) { animal.swim(); } else { animal.fly(); } }第三种是Array.isArray用来判断数组。第四种是自定义类型守卫用is关键字写一个返回布尔值的函数这是我最推荐的一种方式因为判断逻辑可以复用。function isString(value: unknown): value is string { return typeof value string; } function parse(value: string | number) { if (isString(value)) { console.log(value.length); } }3.3 可辨识联合一劳永逸的“类型标记”模式可辨识联合Discriminated Union是我最喜欢的一个 TS 特性。它的核心思想是给联合类型中的每项都加一个共同的字面量属性用这个属性来做收窄判断。type NetworkState | { status: loading } | { status: success; data: string } | { status: error; error: Error }; function handle(state: NetworkState) { switch (state.status) { case loading: console.log(加载中); break; case success: console.log(state.data); // 这里 TS 能确定只有 success 才有 data break; case error: console.log(state.error.message); break; } }这个模式在前端状态管理里非常值得推广。比如请求一个列表状态可能是loading、success、error每种状态对应的数据字段不一样用可辨识联合就能保证你在处理success时一定拿得到data不会误用到错误对象。3.4 穷尽检查用never兜底配合可辨识联合还有一个进阶技巧用never类型做穷尽检查。所有类型都处理完之后default分支里可以接收一个never类型如果未来有人新增了状态却忘了处理TS 会在编译期提醒你。function assertNever(value: never): never { throw new Error(未知状态: ${JSON.stringify(value)}); } function handle2(state: NetworkState) { switch (state.status) { case loading: return; case success: return; case error: return; default: assertNever(state); // 如果没全覆盖这里会报错 } }4. 接口、类型别名与泛型组织类型的三种核心武器4.1 interface 与 type看似一样用法有讲究很多 TS 新手都会纠结interface和type到底有什么区别。我的观点很简单能表达需求的场景里优先用interface需要联合类型、元组、映射条件等能力时用type。// interface 适合描述一个对象的形状 interface User { id: number; name: string; email?: string; // 可选属性 readonly createdAt: Date; // 只读属性 } // type 适合定义联合类型、交叉类型 type ID string | number; type Point [number, number]; type WithUserID { id: ID } { user: string };这里要特别提一个面试高频点interface支持声明合并同一个名字的接口多次声明会被合并到一起type则不行。这个特性对扩展第三方库的类型声明非常有用但对日常业务代码来说差异其实不大。我的经验是团队里定好一个规则然后统一用即可核心是让代码读起来一致。4.2 泛型把“某个类型”变成“任意类型但保持约束”泛型大概是 TS 里让新手最头疼的概念之一。但大家不用被它吓到你可以把泛型理解成“类型的参数”。你写函数时传入值泛型则支持你在使用时传入一个类型。// 定义一个返回数组第一项的泛型函数 function firstT(arr: T[]): T | undefined { return arr[0]; } const num firstnumber([1, 2, 3]); // number | undefined const str first([a, b]); // TS 自动推断为 string | undefined实际业务里我写泛型最多的地方有两个。一个是封装网络请求函数interface APIResponseT { code: number; message: string; data: T; } async function requestT(url: string): PromiseAPIResponseT { const res await fetch(url); return res.json(); } const userRes await requestUser(/api/user);另一个是写 React 组件的 Propsinterface ListPropsT { items: T[]; renderItem: (item: T) React.ReactNode; } function ListT(props: ListPropsT) { return {props.items.map(props.renderItem)}/; }4.3 泛型约束让类型参数更有底线泛型虽然可以接任意类型但我们在实际调用时通常希望它满足一定的条件。比如我希望传入的类型一定是有.length属性的就可以加一个extends约束。function getLengthT extends { length: number }(arg: T): number { return arg.length; } getLength(hello); // 可以 getLength([1, 2, 3]); // 可以 getLength(42); // 报错number 没有 length 属性还有一个实战里很有用的默认泛型参数写法适合设置兜底类型function createArrayT string(count: number): T[] { return Array.from({ length: count }, () as T); }4.4 映射类型与条件类型高级但不玄乎映射类型和条件类型是做类型工具库的利器。keyof拿对象的键in遍历键typeof可以提取值的类型这几个组合起来可以写出很多强大的类型操作。interface Todo { title: string; completed: boolean; } // 把所有属性变成可选 type PartialTodo { [K in keyof Todo]?: Todo[K] }; // 把所有属性变成只读 type ReadonlyTodo { readonly [K in keyof Todo]: Todo[K] };其实 TS 内置了Partial、Readonly、Record、Pick、Omit、Exclude等通用工具类型平时直接可以用。我自己最常用的是Pick和Omit——比如从接口里挑出某几个字段作为函数参数或者从完整实体里剔除掉某个字段后再返回。type TodoWithTitle PickTodo, title | completed; type TodoWithoutCompleted OmitTodo, completed;5. 类、抽象类和装饰器把 OOP 能力带进前端5.1 TS 对类的增强访问修饰符与参数属性ES6 的类写法在 TS 里全部支持但 TS 还加了一些老前端特别舒服的东西public、private、protected访问修饰符还有readonly。class Person { public name: string; private age: number; protected email?: string; readonly id: number; constructor(name: string, age: number, id: number) { this.name name; this.age age; this.id id; } }这里有个我常用的省事写法叫“参数属性”在构造函数的参数前直接加修饰符TS 会自动帮你声明并赋值class Person { constructor( public name: string, private age: number, readonly id: number ) {} }少写了三行属性声明加三行赋值看起来舒服多了。5.2 抽象类与接口实现什么时候才值得用类在 React 函数组件占主流的今天类在前端业务代码里的存在感确实比以前低了很多但在一些需要强约束、强复用的场景里仍然非常能打。比如你就是想在多个业务类里统一某个流程abstract class ReportGenerator { abstract generateHeader(): string; abstract generateBody(): string; output(): string { return ${this.generateHeader()}\n${this.generateBody()}; } } class CSVReport extends ReportGenerator { generateHeader() { return id,name,date; } generateBody() { return 1,张三,2026-01-01; } }抽象类规定了子类必须先实现哪些方法再执行公共逻辑这种模板方法模式在代码里重现时很有价值。但我个人的经验是如果只是单纯的数据结构定义优先用接口或类型别名而不是类只有当你需要“方法实现 状态共享”时才考虑类。别为了用面向对象而用面向对象。5.3 装饰器日志、鉴权、依赖注入的利器装饰器目前在前端生态里主要出现在 Angular/NestJS 这类框架中React 项目里很少直接使用但面试偶尔会问到所以这里把核心语法过一遍。function logMethod( target: any, propertyKey: string, descriptor: PropertyDescriptor ) { const original descriptor.value; descriptor.value function (...args: any[]) { console.log(调用 ${propertyKey}参数, args); return original.apply(this, args); }; } class Calculator { logMethod add(a: number, b: number) { return a b; } }要注意的是装饰器目前还不是 ECMAScript 标准使用前需要在tsconfig.json里开启experimentalDecorators: true并且要清楚不同版本的 TS 在编译输出上可能有差异。如果你只是做普通的 React 业务我的建议是把装饰器当作“了解即可”的内容不要在没有必要的情况下引入额外复杂度。6. TypeScript 与 JavaScript 的区别不只是加了类型更是工程化思维的分水岭6.1 编译时与运行时最本质的区别这个问题在面试里出现频率极高很多人回答到“TS 有类型JS 没有”就结束了但其实面试官更想听到的是“编译时 vs 运行时”这个层面。JS 是一门“解释型语言”代码交给引擎就直接执行类型错误会在运行到那一行时才暴露。TS 则多了编译步骤在代码交给引擎之前编译器先把类型错误拦截下来。这带来的结果就是很多低级 bug 从“运行期崩溃”提前到了“编码期提示”。可以确定的是TS 最终编译生成的还是 JS 代码浏览器和 Node.js 真正执行的依然是 JS。所以 TS 不是“另一种语言”而是“带类型检查的 JS 工具链”。6.2 开发体验上的几个直观差异我用纯 JS 写项目时最痛苦的就是“不确定这个函数返回什么”。调用一个同事写的工具函数鼠标悬停过去只看到一堆function xxx(a, b) { ... }参数含义全靠函数名猜。TS 环境下不一样函数签名、参数类型、返回值类型全部清晰可见。差异还体现在配置上。一个纯 JS 项目的隐性约定非常多比如“这个配置字段是字符串还是数组”“有没有默认值”全靠读源码或文档才知道。TS 项目可以用类型定义把配置项全部锁定传错了参数类型编译器直接报错写错的概率大幅下降。6.3 一份可以照着准备的面试题清单这里把我面试别人时常问的 TS 相关问题整理一下有些是基础语法考察有些是看实际经验any、unknown、never分别代表什么使用场景是什么interface和type的核心区别有哪些说一下类型收窄的几种方式并举例说明。泛型是什么为什么要用泛型可辨识联合是什么适合解决什么问题TS 中的readonly和 JS 里的Object.freeze有什么区别const在 TS 类型推断中有什么特殊表现什么是声明合并实际项目中什么时候会遇到映射类型是什么怎么把一个接口的所有属性变成可选如果你能对着这些问题流畅回答并且能写出手写代码那 TypeScript 面试这一关基本就过了。7. 工程化实战tsconfig 配置、废弃警告和迁移踩坑7.1 tsconfig.json 里必须要懂的配置项TS 项目从建仓第一天就要面对tsconfig.json。很多新手直接复制脚手架模板之后遇到了类型检查不生效、路径别名报错等问题才发现自己其实没搞懂关键的几个开关。我把最该优先搞明白的配置列成一张速查表配置项作用我的经验target编译产物要兼容到哪个 ECMAScript 版本现代浏览器直接设ES2020以上module打包器用什么样的模块方案Vite/webpack 项目通常用ESNextstrict开启严格类型检查必须开不开等于没戴安全帽moduleResolution模块解析策略现代库和 Vite 用bundlerbaseUrlpaths路径别名配合打包器配置一起使用outDir编译输出目录服务端 Node 项目必须设declaration是否生成.d.ts类型声明文件引用你的 TS 项目的其他项目才需要开esModuleInterop让 CommonJS 模块能用默认导入新项目一般直接true7.2 一条必然遇到的警告baseurl 弃用热词里出现的option baseurl is deprecated and will stop functioning in typescript 7.0我太熟悉了因为最近用新版本 TS 建项目时就被这条警告刷了屏。先说明这是怎么回事TypeScript 官方在最新版本中开始弃用baseUrl配置项建议不再用它作为路径解析的基础起点。之前的做法通常是在tsconfig.json里写{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }现在官方推荐直接使用相对路径或者借助打包器比如 Vite 的resolve.alias来解析路径别名。如果paths里不再依赖baseUrl可以直接写成{ compilerOptions: { paths: { /*: [./src/*] } } }我实测下来只要把 paths 里的路径写成相对tsconfig.json所在目录的路径就算不写baseUrl也能正常工作。如果你已经在项目里依赖了baseUrl来解析模块建议先改成相对路径方式再逐步清理掉其他引用这样能平稳过渡到 TS 7.0 而不被警告打扰。7.3 从纯 JS 项目迁移到 TS 的实战顺序我之前把一个运维平台的纯 JS 前端项目迁移到 TS断断续续做了一周。总结下来最顺的迁移顺序是这样的第一步先把构建工具换成支持 TS 的Vite 或 Webpack ts-loader创建一个最简tsconfig.json先不开启strict保证全量代码能编译通过。第二步把公共工具函数、API 请求层这些“基础依赖”文件改成.ts加上完整的类型定义。因为很多业务页面都依赖这些文件先把地基补好后面才能少报错。第三步开启strict开始处理潜在的null和undefined风险。这一步最费时间因为暴露出来的都是以前 JS 里的“脏数据”但确实值得花时间修。第四步逐个改造页面组件和业务模块。建议从最核心、修改最频繁的页面开始因为这个投入产出比最高。第五步把any清理到最低限度。如果非用不可加一行注释说明为什么这里要用any方便后面接手的人。7.4 一些写 TS 时的习惯建议最后聊几个我维护多个 TS 项目后形成的习惯。第一严格模式下宁可多用unknown也不要急着断言成具体类型。类型守卫处理一步安全一步。第二团队里尽量统一interface和type的使用边界别再让新人纠结该用哪个。第三公共 API 的入参和返回值尽量写职责清晰的类型名称变量类型也跟着命名走代码读起来会舒服很多。还有一点关于泛型的经验不要为了“看起来高大上”而过度设计泛型。如果一个函数实际只接收User类型你强行写一个T extends User只是在增加无谓的复杂度。先写实体类型等确实多个地方复用同一个逻辑时再往泛型上靠这才符合工程效率。7.5 我现在怎么看待“会用 TypeScript”这件事入职带新人的时候我通常不会问别人背没背过什么语法概念而是直接让他看一小段用any堆起来的代码问“你觉得这段代码的类型风险在哪里”。能准确说出风险并且能基于类型系统给出一版更健壮的写法在我心里才算真正会用 TypeScript。我也是从“JS 写的很快TS 还要写类型太麻烦”一路走过来的。真正转变态度是在一次大型重构里我在纯 JS 代码里全局搜索一个变量时发现它竟然有 17 处调用改错一处就直接线上事故。后来用几个月的碎片时间把类型补全重构时的信心完全不同。所以我给所有前端同行的建议都是别把 TypeScript 当成必须完成的面试题把它当成保护你代码安全、让你敢于在大型项目里动手重构的搭档这样的心态下再去过一遍核心语法你会主动想把每一处any都消灭干净而这篇核心语法速览就是我们迈出这一步的起点。