如果你写过一段时间的JavaScript,大概率经历过这种时刻:一个函数跑得好好的,换个调用方式突然就报错了;一段别人留下的老代码,改了一行数据格式,十几个地方跟着崩;又或者一个对象明明有某个字段,你愣是想不起来它叫什么,只能一遍遍console.log去翻。TypeScript解决的就是这类问题——它给JavaScript加了一层类型系统,把很多运行时才会暴露的错误提前到写代码的瞬间就告诉你。这篇入门指南面向的是有JavaScript基础、想转型TypeScript的前端开发,也会覆盖面试常考的核心概念,我会尽量用实际开发里遇到的场景来讲,而不是堆概念。
主体内容不算短,但我会按一条清晰的路线走:先讲TypeScript到底治好了JavaScript的什么病,然后从环境搭建开始,逐步深入类型系统、泛型、高级类型,最后给出存量项目的迁移路线和一个实战案例。放心,读完之后你不仅能看懂别人的TS代码,还能自己动手改造手里的项目。
1. 为什么JavaScript越写越心慌,TypeScript到底解决了什么
1.1 动态类型是爽,但坑也是真的多
JavaScript最大的特点就是灵活:变量可以随时从字符串变成数字,对象可以随意增删属性,函数参数不检查类型,这些特性在小项目里写起来非常痛快。但项目一旦超过一定规模,灵活性就会变成负担。
我给你举一个特别典型的例子。假设你写了一个函数,用来计算用户折扣后的订单金额:
function calcTotal(price, discount) { return price * discount; }函数本身很简单,但调用方可能传入字符串:
const total = calcTotal("99", 0.8); // 结果是 "79.2"?不对,是 NaN更麻烦的是,这种问题不会在写代码的时候暴露,而是要等到用户真的用到了这个页面、控制台刷出一行红色报错,你才后知后觉。一个项目里这样的隐患如果攒了几百个,那每次重构都是一场赌博——改一个公共函数,你根本不知道哪些调用方会炸。
1.2 TypeScript不是新语言,而是带类型检查的JavaScript超集
很多人一听"新语言"就被吓退了,其实TypeScript的本质非常简单:它就是JavaScript,只是在上面叠加了一层类型标注。你写的TS代码最终会编译成JavaScript在浏览器或Node里运行,所以"TS能做的事JS都能做,JS能做的事TS也都能做"。
区别在于,TS在编译阶段会做一次静态检查。所谓静态检查,就是不需要真正运行代码,光靠读代码就能发现类型不匹配的问题。比如上面的calcTotal例子,如果参数标注了number类型:
function calcTotal(price: number, discount: number): number { return price * discount; } calcTotal("99", 0.8); // 编辑器直接划红线报错在保存文件的那一刻,编辑器就会告诉你这里传错了类型,根本轮不到上线让用户踩坑。这就是类型安全的核心价值——把运行时的错误提前到编译期。
1.3 类型安全的价值:把运行时报错提前到编译期
我自己的体会是,类型系统真正的价值不只是少写几个bug,而是它改变了你写代码的方式。当你给一个模块写清楚类型接口之后,调用方只需要看类型签名就能知道该传什么、能拿到什么,整个项目的"沟通成本"会直线下降。
而且现在主流编辑器(VS Code、WebStorm)对TypeScript的提示支持都做得非常好。有了类型标注,自动补全的效果完全不是一个量级:对象有哪些属性、函数有哪些重载、参数该传字符串还是数字,写代码的时候一目了然。
注意:类型检查只是"帮助"而不是"保证"。TS不会帮你拦截所有的逻辑错误,比如
price * discount算错了公式,类型系统是发现不了的。它解决的是"类型层面的错误",不是"业务逻辑错误",这个定位要想清楚。
2. 从零搭好TypeScript开发环境:工具链与tsconfig精讲
2.1 安装和第一个Hello World
搭环境其实比很多人想象中简单。假设你已经有Node.js环境,全局安装或者项目内安装TypeScript都可以:
npm init -y npm install -D typescript然后创建一个hello.ts文件,写上一段带类型的代码:
function greet(name: string): string { return `Hello, ${name}!`; } const message: string = greet("TypeScript"); console.log(message);接着执行编译:
npx tsc hello.ts你会看到目录下多了一个hello.js文件,这就是编译产物。如果你故意给greet传一个数字:
const message: string = greet(123);再跑npx tsc hello.ts,编译器会直接报错,但仍然会生成JS文件——这个行为很多人一开始不习惯,它的设计初衷是"不阻塞编译,但你要知道这里有错"。如果你希望有错误时就停止生成,可以在tsconfig里把noEmitOnError打开。
2.2 tsconfig.json里的关键开关
一个正经项目不会只靠命令行参数管理编译选项,而是通过tsconfig.json统一配置。用命令初始化:
npx tsc --init你会得到一个带大量注释的配置文件。我挑几个影响最大的开关说说:
| 配置项 | 作用 | 我的建议 |
|---|---|---|
strict | 开启所有严格类型检查,包括空值检查、隐式any检查等 | 新项目必须开,迁移老项目可以渐进开启 |
target | 指定编译输出的JavaScript版本 | 按你的运行环境定,现代环境用ES2020+ |
module | 指定模块系统,常见的有CommonJS、ESNext | 看你的项目最终用什么打包器 |
outDir | 编译产物输出目录 | 建议设成dist,别和源码混在一起 |
rootDir | 源码根目录 | 用来控制编译后的目录结构 |
noUnusedLocals | 检查未使用的局部变量 | 建议开启,逼自己清理死代码 |
noImplicitAny | 禁止隐式any | 强烈建议开启,这是TS类型检查的底线 |
strict这个开关尤其值得展开说。它不是一个独立选项,而是一组严格检查的总开关,内部包括strictNullChecks、noImplicitThis、alwaysStrict等多个子选项。对新手来说,最直观的体验变化是:strictNullChecks开启后,null和undefined就不能随便塞给一个普通类型变量了,这逼着你去处理"值为空"的情况。比如:
// strict: false 时,下面这行不报错 let name: string = null; // strict: true 时,直接报错 let name: string = null;想表达"这个值可能是字符串也可能是空",你要写:
let name: string | null = null;这种写法看似麻烦,但它逼你面对"空值"这种在真实项目里最容易出bug的场景。我见过太多线上事故,都是"某个字段是undefined,代码没判断就直接用了"。开着strict写代码,这类事故会少很多。
2.3 三种常见运行方式怎么选
TS本身只是编译器,编译后的JS怎么运行,你可以根据自己的场景选择:
- 开发工具链完整:用Vite、Webpack、Rollup这类打包器,它们内部会处理TS编译,你只需要把
tsconfig.json配好。这也是目前前端项目最主流的方式。 - Node.js环境快速调试:用
tsx或者ts-node。tsx体验更好,可以直接运行TS文件:npx tsx hello.ts - 纯学习练手:直接用官方提供的TypeScript Playground(编辑器里的在线演练场),不需要安装任何东西,左边写TS右边看JS,还能直接看类型推导结果。
我个人建议新手把Playground用起来,尤其是学类型体操的时候,它能实时显示某个表达式推导出来的类型,比在本地折腾环境高效得多。
3. 核心类型系统:从基础类型到泛型,一次讲透
3.1 基础类型和类型推断
TypeScript的基础类型比JavaScript多了一套"配套标注"。数字、字符串、布尔、数组、对象这些你本来就在用,只是多了个冒号写法:
let count: number = 10; let title: string = "hello"; let isDone: boolean = false; let list: number[] = [1, 2, 3]; let tuple: [string, number] = ["age", 30]; // 元组,长度和位置固定不过更推荐的做法是尽量依赖类型推断。也就是说,你能不写类型就不写类型,让TS自己根据赋值推出来:
let count = 10; // 自动推导为 number类型推断不是偷懒,而是让代码更简洁。真正需要你显式标注类型的地方,是函数参数和对外暴露的接口——这些地方的"契约"必须写清楚。
还有几个容易被忽视的类型:
unknown:不知道是什么类型。和any不同,unknown不能直接调用方法或赋值给其他类型,必须先做类型收窄。never:永远不会有返回值的函数,或者永远不会执行完的分支。比如抛异常的函数返回值就是never。void:函数没有返回值时用它。
any和unknown的区别,面试经常问。我的理解是:any是"关闭类型检查",等于把TS重新变回JS,能少用就少用;unknown是"我不知道类型,但我不放弃类型检查",用之前必须判断。实际开发里,解析外部接口返回的JSON数据时,经常先用unknown接住,再做类型断言或守卫。
3.2 interface与type,选谁更合适
在TS里描述一个对象的形状,有两种方式:接口(interface)和类型别名(type alias)。
interface User { id: number; name: string; } type UserType = { id: number; name: string; };两者在大多数场景下可以互换,但有一些差异值得记住:
interface支持声明合并:同一个名字的接口可以定义多次,TS会合并它们的属性。这个特性在扩展第三方库的类型时很有用。type更灵活:可以表示联合类型、交叉类型、元组、原始类型别名等interface做不到的东西。interface可以extends,type可以用交叉类型&实现类似效果。
我的个人习惯是:能用interface描述的对象优先用interface,遇到联合类型、工具类型组合这种"计算出来的类型"再用type。
这里还要提一个高频面试点:interface和type的“extends”实现有什么不同。interface继承用的是extends关键字,type组合用交叉类型&。更关键的区别在于,如果两个类型里有同名字段但类型不一致,interface会报错,交叉类型会得到never之类的诡异结果,所以用&组合时一定要小心字段冲突。
3.3 泛型:给函数和组件加上"可插拔"的类型
泛型是TypeScript里最容易被新人卡住的概念,但其实可以非常通俗地理解:当你写一个函数时,这个函数并不关心具体处理什么类型,但又希望调用它时能保持类型信息,就用泛型。
举个例子,你想写一个取数组第一个元素的函数。如果用any,类型信息就丢了;用泛型,类型就能跟着调用场景走:
function first<T>(arr: T[]): T | undefined { return arr[0]; } const num = first([1, 2, 3]); // num 推导为 number const str = first(["a", "b"]); // str 推导为 string这个T就是一个"类型变量",你调用的时候TS会根据传入的数组自动推断出T具体是什么。读法上,可以理解为"函数first接收一个T类型的数组,返回T类型或undefined"。
泛型还可以加约束,用extends限定"T必须满足某个形状":
function getLength<T extends { length: number }>(arg: T): number { return arg.length; } getLength("hello"); // 可以,字符串有length getLength([1, 2, 3]); // 可以,数组有length getLength(123); // 报错,数字没有length这种约束在实际开发里特别常用,尤其是封装通用组件、通用请求函数时。例如封装一个请求方法:
interface ApiResponse<T> { code: number; data: T; message: string; } async function fetchData<T>(url: string): Promise<ApiResponse<T>> { const res = await fetch(url); return res.json(); }调用的时候,你可以指定想要的返回类型:
interface Product { id: number; name: string; } const data = await fetchData<Product>("/api/product/1"); // data.data 的类型就是 Product这一下就让接口返回的数据有了完整的类型提示,不用再写一堆as any去断言了。
4. 函数与类的类型化:把JavaScript的"自由"收进笼子里
4.1 函数参数、返回值与重载
函数是JavaScript的核心,类型化之后变化也最明显。基本写法:
function add(a: number, b: number): number { return a + b; }或者用箭头函数表达式:
const add: (a: number, b: number) => number = (a, b) => a + b;默认参数、可选参数、剩余参数也都有对应的写法:
function buildName(first: string, last?: string): string { return last ? `${first} ${last}` : first; } function sum(...nums: number[]): number { return nums.reduce((acc, n) => acc + n, 0); }函数重载是一个容易让新手困惑但也很有用的特性。它不是说"同一个函数可以写多个实现",而是"同一个函数,根据参数类型不同,返回值类型也不同,但内部只有一个实现"。写重载时要先把所有签名列出来,再写实现:
function parse(input: string): string[]; function parse(input: string[]): string; function parse(input: string | string[]): string | string[] { if (Array.isArray(input)) { return input.join(","); } return input.split(","); }这么写的好处是,调用方传入数组时,TS能精确知道返回的是string,而不是string | string[]。这种"根据输入推断输出"的能力,会让封装的工具函数好用很多。
4.2 类的修饰符与抽象类
TS里的类在JavaScript的class基础之上,多了一套访问修饰符:
public:默认,任何地方都能访问。private:只能在类内部访问。protected:只能在类内部和子类中访问。readonly:只读属性,必须在声明时或构造函数里赋值。
class Animal { protected name: string; private age: number; constructor(name: string, age: number) { this.name = name; this.age = age; } public speak(): void { console.log(`${this.name} makes a sound`); } }private在TS里是"编译期检查",也就是说编译成JS之后,它其实还是普通的属性,运行时无法真正阻止访问。这也是面试里经常提到的点:TS的访问修饰符更多是给开发者看的"约定",不是运行时安全机制。
抽象类也是TS相对JS新增的概念。用abstract修饰的类不能直接实例化,只能被继承,抽象方法必须由子类实现:
abstract class Shape { abstract getArea(): number; toString(): string { return `Area: ${this.getArea()}`; } } class Circle extends Shape { constructor(private radius: number) { super(); } getArea(): number { return Math.PI * this.radius ** 2; } }这种"定义骨架、让子类填空"的模式,在需要强制子类实现统一接口时特别好用。不过实际项目里,我更推荐优先用interface + 组合的方式,抽象类容易造成过深的继承链,维护起来反而麻烦。
4.3 类型收窄与类型守卫
当你拿到一个联合类型(比如string | number)时,怎么确定当前这个值到底是哪个类型?这个过程叫类型收窄(narrowing),实现收窄的代码叫类型守卫。
最简单的是typeof:
function format(input: string | number): string { if (typeof input === "string") { return input.trim(); } return input.toFixed(2); }typeof只适合判断基础类型。对象类型需要换别的手段:
instanceof:判断是否某个类的实例。in:判断对象是否存在某个属性。- 自定义类型谓词:返回值写成
arg is SomeType。
自定义类型谓词是个很强大的工具,比如你有一个"用户"和"游客"的联合类型:
interface User { id: number; email: string; } interface Guest { token: string; } function isUser(item: User | Guest): item is User { return "email" in item; } function handle(item: User | Guest) { if (isUser(item)) { console.log(item.email); // 这里item被收窄为User } else { console.log(item.token); // 这里item被收窄为Guest } }掌握类型守卫的意义在于,很多运行时判断逻辑(判空、判字段存在、判断数组/对象)都可以顺便把类型收窄做掉,一箭双雕。
5. 高级类型实战:映射类型、条件类型和内置工具类型
5.1 联合类型与交叉类型的应用场景
联合类型用|表示"或",交叉类型用&表示"且"。看起来简单,但应用场景非常多。
联合类型最常见的场景是描述"这个值只能是这几个之一":
type ButtonSize = "small" | "medium" | "large"; type Status = "success" | "error" | "loading";字符串字面量联合类型在业务里特别好用,它相当于把"魔法字符串"变成了一组可见的合法值,写错了编译器直接报错,比文档管用多了。
交叉类型则常用于组合多个对象类型:
type BasicInfo = { name: string; age: number }; type Contact = { email: string; phone: string }; type Person = BasicInfo & Contact;交叉类型在复用一个"公共基础类型"又需要追加字段时很顺手。但注意别和interface继承混用出歧义,如果两个类型定义了同名字段且类型不一致,&的结果会让你调试到崩溃。
5.2 映射类型和条件类型的原理
映射类型(mapped type)是"遍历已有类型的每个属性,生成一个新类型"。最经典的例子是TS内置的Partial,它的原理大致是:
type Partial<T> = { [P in keyof T]?: T[P]; };这里的keyof T取出T的所有属性名组成一个联合类型,in遍历这个联合类型,T[P]取对应属性的类型。理解了这段代码,你就理解了Partial是怎么"把每个属性变成可选"的。
再配上-修饰符,还能把可选变成必选,这就是Required的原理:
type Required<T> = { [P in keyof T]-?: T[P]; };条件类型(conditional type)则是"根据某个类型判断的结果,选择不同的类型":
type IsString<T> = T extends string ? true : false;T extends string ? true : false这行代码的意思是:如果T能被赋值给string类型,结果就是true,否则是false。条件类型配合泛型,可以用来写一些"类型层面的逻辑"。不过这类技巧在封装库时才常用,日常业务开发里更常见的是直接用内置工具类型。
5.3 几个高频内置工具类型解析
如果你没时间深究类型体操,至少要把这几个内置工具类型用熟,它们能省下大量重复的类型定义:
| 工具类型 | 作用 | 使用场景 |
|---|---|---|
Partial<T> | 所有属性变为可选 | 表单更新的入参 |
Required<T> | 所有属性变为必选 | 必须要填全字段的接口 |
Pick<T, K> | 从T中挑出一部分属性 | 列表页只需要部分字段 |
Omit<T, K> | 从T中排除一部分属性 | 创建数据时排除id |
Record<K, T> | 构造一个键为K类型、值为T类型的对象 | 枚举映射、字典结构 |
ReturnType<T> | 获取函数类型的返回值类型 | 推导函数返回类型 |
Parameters<T> | 获取函数参数的类型元组 | 包装函数时透传参数 |
举个例子,Record在写字典映射时非常好用:
type HttpCode = number; type HttpMessage = Record<HttpCode, string>; const messages: HttpMessage = { 200: "OK", 404: "Not Found", 500: "Internal Server Error", };再看Pick和Omit的对比。假设你有一个完整的Product类型,但前端列表页只需要id和name,就完全没必要重新定义一个瘦身接口:
interface Product { id: number; name: string; price: number; description: string; createdAt: Date; } type ProductListItem = Pick<Product, "id" | "name">; type ProductCreateInput = Omit<Product, "id" | "createdAt">;这样ProductCreateInput会跟着Product的变化自动更新,加了一个字段,创建入参也自动带上,少维护一组"容易漂移"的类型定义。
6. 存量JavaScript项目平滑迁移的实操路线
6.1 渐进式改造:allowJs与checkJs的妙用
很多人接手老项目时想引入TS,但项目动辄几十个文件,不可能一次性全部改完。渐进式迁移是唯一现实的做法。
第一步,先把tsconfig里打开两个开关:
{ "compilerOptions": { "allowJs": true, "checkJs": false, "outDir": "dist" }, "include": ["src"] }allowJs: true让TS编译器可以处理.js文件,这样你就能把TS和JS混在一个项目里。然后在新写的文件里用.ts后缀,老的.js文件先不动,等编译通过、构建管线稳定后,再逐个把.js改成.ts。
第二步,把老文件从"完全不检查"逐步过渡到"检查但不报错"。你可以在某个.js文件顶部加一行注释:
// @ts-check这会让TS对这个文件做类型检查,但不会硬性阻止编译。你会发现编辑器开始给你划黄线提示,但不影响运行。根据提示一个一个改,改完一个挪一个。
第三步,把checkJs改成true,或者直接把strict打开,让整个项目进入严格模式。这时候剩下的小问题会暴露出来,但因为你已经渐进改了大半,压力会小很多。
提示:改tsconfig时建议先提交一次git,然后再逐项打开严格检查。如果报错太多,不要硬扛,可以先把
strict关掉,等文件迁移得差不多了再开。记住,strict是一个目标,不是一个起点。
6.2 第三方库的类型声明怎么处理
迁移过程中最常见的问题是:项目里用了一堆第三方库,这些库本身没有写TS类型,编辑器里全是红色的"Could not find a declaration file"报错。
解决办法分几种情况:
库自带类型:直接能用,比如
axios、lodash的官方包就带了.d.ts文件。社区类型包:大部分流行库在
@types组织下都有类型声明,安装方式:npm install -D @types/node npm install -D @types/react没有类型也没有社区类型:自己写一个最小声明文件。在项目里创建一个
src/types/xxx.d.ts:declare module "some-untyped-lib" { export function doSomething(input: string): void; const defaultExport: any; export default defaultExport; }
自己写声明文件的粒度按需来,用到什么声明什么,不用为了"完整"把整个库摸一遍。对业务代码来说,一个只声明了你用到的函数的declare module就够了。
6.3 迁移中的经典翻车现场
我迁移过几个老项目,踩过的坑总结下来就这几类:
第一,any泛滥。刚开始改不动,很多人会图省事把所有报错的地方都改成any。结果就是"TS的壳、JS的核",类型检查形同虚设。我的建议是:一段代码如果实在搞不清类型,用unknown接住,配合类型守卫收窄;如果必须用any,加一行注释说明"为什么这里放弃了类型检查",至少给下一任维护者留个交代。
第二,回调函数和this指向。JS里函数内部的this是由调用方式决定的,TS对this的检查有自己的规则。老代码里如果大量用function关键字而不是箭头函数,迁移时经常出现"this隐式是any"或者"this类型不匹配"的报错。解决办法是明确标注this参数,或者干脆趁迁移之机把相关逻辑改成箭头函数。
第三,类型和运行时混淆。刚学TS的人容易以为"类型检查能替代运行时校验",实际上接口返回的数据在运行时是不可信的。你声明了Product类型,不代表后端一定会返回符合这个形状的数据。TS只是给你一个"默认信任"的模型,真正的数据校验还是需要运行时处理的,特别是用户输入和第三方接口的数据。
7. 实战小案例:用TypeScript重构待办事项模块
7.1 先写一个"能跑"的JavaScript版本
空谈类型系统太抽象,我拿一个最常见的待办事项模块做例子。先用JavaScript写个"看似正常"的版本:
const todos = []; function addTodo(title) { todos.push({ title, done: false }); } function toggleTodo(index) { todos[index].done = !todos[index].done; } function renderTodo(todo) { return `${todo.title} - ${todo.done ? "已完成" : "未完成"}`; }这段代码的问题,等你写多了自然能感觉到:
addTodo不检查title是不是字符串,传个对象、数字都能塞进去。toggleTodo不检查index是否是有效数字,越界直接报错。- 维护todo的人也不知道这个对象里到底有哪些字段,全靠猜。
如果这个模块只有你自己用还行,一旦三个人一起开发,很快就会有人写出todo.isCompleted = true这种"似乎合理但完全没用"的代码。
7.2 加上类型之后发生了什么
用TypeScript重写一遍:
interface Todo { id: number; title: string; done: boolean; createdAt: Date; } type TodoStatus = "active" | "completed"; class TodoStore { private todos: Todo[] = []; private nextId = 1; addTodo(title: string): Todo { const todo: Todo = { id: this.nextId++, title, done: false, createdAt: new Date(), }; this.todos.push(todo); return todo; } toggleTodo(id: number): void { const todo = this.todos.find((t) => t.id === id); if (!todo) { throw new Error(`Todo ${id} not found`); } todo.done = !todo.done; } getTodos(status?: TodoStatus): Todo[] { if (status === "completed") { return this.todos.filter((t) => t.done); } if (status === "active") { return this.todos.filter((t) => !t.done); } return [...this.todos]; } }对比一下就能看出几个明显变化:
Todo接口把"这个对象长什么样"写死了,任何人改代码都能看到完整字段。toggleTodo参数从index换成了id,并且做了"找不到就抛错"的处理,杜绝了越界问题。TodoStatus把过滤状态限定为两个合法值,传"deleted"这种字符串直接编译不过。
这些改动没有增加太多代码量,但整个模块的可维护性和安全性完全不同了。尤其是多人协作时,其他人调用addTodo,编辑器会自动提示"需要一个string类型的title",连函数的文档都不用看了。
7.3 这次重构给我的直观感受
你可能觉得这个例子太简单,但真实项目里大量bug就是在这种"简单模块"里积累出来的。类型系统不给你的代码增加魔法,它只是把"约定"变成了"机器强制检查的规则"。
重构完之后,最大的变化不是bug立刻消失了,而是改代码的时候胆子变大了。以前改Todo的数据结构,我得全局搜索所有用到的地方,生怕漏掉一个;现在TS会在所有用错的地方划红线,我只要跟着报错一个个改就行。这种"编译器当保镖"的感觉,用过一次就回不去了。
如果有一天你要调整Todo的字段,比如加一个priority字段,需要做的只是:更新Todo接口、更新创建处、然后看TS在哪里报错补哪里。这个流程带来的安全感和效率提升,是任何代码审查流程都给不了的。
8. 面试高频题与工程避坑清单
8.1 面试官最爱问的TypeScript问题
因为TypeScript现在几乎是前端岗位的标配,面试里出现的频率非常高。我整理了最常见的几类问题,你可以对照自测:
| 问题 | 关键答题点 |
|---|---|
| interface和type有什么区别 | 声明合并、联合类型支持、继承方式、同名字段冲突处理 |
| any和unknown有什么区别 | any关闭检查,unknown保留检查、使用前必须收窄 |
| never类型有什么用 | 表示不可能发生的值,常用于穷举检查、抛错函数返回值 |
| 泛型是什么,怎么加约束 | 类型参数T、extends限定形状、典型例子是数组处理和请求封装 |
| typeof和keyof怎么用 | typeof取值的类型、keyof取属性名联合类型 |
| 什么是类型守卫 | typeof、instanceof、in、自定义类型谓词,实现收窄 |
| 如何让一个类型的所有属性可选 | Partial,原理是映射类型,[P in keyof T]?: T[P] |
| implements和extends的区别 | 实现接口要满足形状,继承类可以获得实现和属性 |
| readonly和const有什么区别 | const是变量不可重新赋值,readonly是属性不可修改 |
| 怎么给没有类型的第三方库写声明 | 在.d.ts里declare module,按需声明用到的函数 |
回答这些问题的核心不是背定义,而是举出实际场景。比如问never,你可以说"写一个checkNever函数做穷举检查,当联合类型新增成员时,编译期就能发现漏处理的分支",一说出场景,面试官就知道你是真用过,而不是背了八股。
8.2 日常开发中必须养成的类型习惯
最后聊几个我从实际项目里总结出来的习惯,都是踩坑踩出来的:
第一,不要一上来就追求"最严格的类型"。类型系统的目标是把代码写清楚,不是给你自己找麻烦。如果一个类型写得太绕,同事看不懂,那这个类型的价值就打了折扣。能简单就不炫技,这是我在团队里反复强调的原则。
第二,学会阅读类型报错。新手看到一大段带|和>的报错就头大,其实TS的错误信息里90%的线索都在第一行:错误发生在哪个文件哪一行、期望什么类型、实际拿到什么类型。先把这三样读明白,剩下的大段推导可以先跳过。
第三,多用编辑器提供的快速修复。VS Code里遇到类型错误,鼠标悬停往往会有"快速修复"选项,比如自动补全as const、自动添加类型断言。这对学习尤其有用,你可以先让编辑器修一遍,再看它改了什么,理解它为什么这么改。
第四,给队友留"逃生通道"。引入TS的项目,团队里一定有人不熟悉。可以约定:如果某个三方库实在没有类型,允许在.d.ts里做一个最小声明,但不允许在业务代码里随便写as any。把"临时妥协"限制在可控范围内,而不是让它扩散。
第五,把tsconfig纳入code review范围。很多人配好tsconfig就不动了,其实随着项目发展,一些严格检查是可以逐步打开的。比如noUnusedLocals、noFallthroughCasesInSwitch这种小开关,打开之后能提前发现不少低级问题。每过一两个迭代,花五分钟看看tsconfig里还有没有能开的开关,收益非常稳定。
我自己做技术选型时,对"要不要引入TypeScript"这件事的答案几乎从来都是"要"。不是因为它时髦,而是因为它确实解决了JavaScript在大规模协作中最痛的一个点:类型信息只能靠人传人,容易断、容易错、容易过时。类型把这份信息固化在代码里,让编辑器、编译器、后来的维护者都能随时读到。这种"把隐性的约定变成显性的检查"的思路,不仅在TypeScript里成立,在任何需要多人协作的工程里都成立。
如果你正打算在一个老项目里引入TS,我的建议很简单:别追求一步到位,先配好allowJs,从最核心的数据模型开始加类型,每改完一个模块就跑一遍测试。这个过程不会一两天就结束,但只要你坚持下来,就会发现那个"改一行代码心里就发慌"的阶段,不知不觉就过去了。