如果只能给TypeScript实战项目提一条最佳实践建议我会说把类型当成架构的一部分来设计而不是当成编译器的约束来应付。这个判断来自我参与和围观过的不少项目——凡是前期就把类型边界定义清楚的项目后期接手、重构、加需求都相对顺畅反之一开始用any打天下的项目到了一定规模每次改接口字段都是一次全项目范围的“考古”。现在很多开发者习惯让AI帮着生成TypeScript代码这带来一个微妙的副作用AI可以把类型写对但很难帮你判断“这里的类型应该怎么设计才是对的”这恰恰是实战能力的核心分水岭。这一章适合已经会写interface、type、泛型基础用法但在真实项目里总觉得“类型系统跟我对着干”的同学。我会把日常开发里真正高频的场景拉出来讲——包括前后端类型契约、NestJS搭配TypeScript的常见坑、tsconfig里那些翻车配置、以及面试里反复出现但很多人答不到点子上的类型题目。每一节都来自真实项目的沉淀没有空话。1. 把类型设计当架构实战项目中的第一步大部分项目启动时的顺序是先起路由、先写页面、先调接口类型都是“用到了再说”。这个顺序在Demo阶段没有毛病但一旦进入多人协作和持续迭代类型缺失的代价就开始显现。我见过最典型的一个例子后端接口改了字段名前端项目里十几个页面都在用这个字段由于没有统一类型定义全局搜索出来三十多处使用点只好逐个排查。如果一开始就定义了接口类型编译器会把这些使用点全部标红十分钟收工。所以我的建议是在动手写业务代码之前先把这一阶段的API契约类型写出来。不是写完之后补类型而是先有类型再有实现。这个顺序的差异决定了类型系统是帮你看门的守卫还是跟着你屁股后面擦地的保洁。1.1 从API契约开始建模先写类型再写代码API契约类型通常长这样// api/user.ts export interface UserInfo { id: string; name: string; avatar: string; email?: string; createdAt: string; } export interface GetUserListParams { page: number; pageSize: number; keyword?: string; } export interface GetUserListResponse { list: UserInfo[]; total: number; hasMore: boolean; }定义好之后接口请求函数通过泛型把“返回体的类型”和“实际请求”绑定在一起import type { GetUserListParams, GetUserListResponse } from ./api/user; export async function fetchUserList( params: GetUserListParams, ): PromiseGetUserListResponse { const { data } await axios.get(/api/user/list, { params }); return data; }为什么要强调“先写类型再写代码”因为这个顺序强制你在动手前思考接口要返回什么、可选的字段有哪些、翻页用页码还是游标。这些问题如果在写代码的过程中才去想很容易因为偷懒而含糊过去后面积累成技术债。我在项目里还有一个习惯给请求函数的返回值加一层包装把“接口成功”和“接口失败”处理成判别联合。这样调用方不需要去记接口的约定靠类型收窄就能写出正确的分支逻辑。export type ApiResultT | { ok: true; data: T } | { ok: false; code: number; message: string };你可能觉得每次请求都多包一层很啰嗦但正是这个“啰嗦”让错误处理变得无处可逃——你没法在类型层面上忽略失败分支。前端项目里最常见的线上bug就是“接口挂了但页面还是按成功去渲染”。有了判别联合这种bug在编译期就没有藏身之处。1.2 判别联合让非法状态在编译期暴露实际项目里状态机很常见比如一个上传组件有“未上传”“上传中”“已成功”“已失败”几种状态。很多人的第一反应是写一个宽泛的对象interface UploadState { status: string; percent: number; url?: string; error?: string; }这个类型的问题在于status是string可以随便传一个pendingxxxpercent在“未上传”状态时有没有意义url在“上传中”状态时会不会存在error在“成功”状态下会不会被误读类型层面完全没有约束全靠程序员自觉。改成判别联合之后事情就简单了type UploadState | { status: idle } | { status: uploading; percent: number } | { status: done; url: string } | { status: error; message: string }; function renderStatus(state: UploadState) { switch (state.status) { case idle: return 等待上传; case uploading: return 上传中 ${state.percent}%; case done: return 完成${state.url}; case error: return 失败${state.message}; } }在这个写法下多余的属性访问、状态拼错、非法组合全部在编译期被拦截。这正是前面说的“把类型当架构”的含义——你通过类型划分了“合法的状态集合”之后所有编写和重构都在这个集合内进行不可能跑偏。我自己的习惯是只要一个概念处于“可能有若干个具体形态”的状态就优先考虑判别联合。它比继承更轻量、比普通宽类型更安全是实战里性价比最高的类型设计手段。注意判别联合的关键是那个可辨识的标签字段用字面量类型而不是string否则联合类型会被拓宽成一个普通string失去收窄能力。1.3 泛型边界设计函数的“接口”而不是“实现”很多初学者会用泛型来“躲避”类型检查比如function getValueT(key: string): T { return JSON.parse(localStorage.getItem(key) || {}); }这个写法的问题在于T没有边界调用方写成getValuenumber(foo)也能过但实际上存储的内容可能是个字符串。泛型的价值在于表达“类型之间的关联”而不是给调用方一个随便指定类型的后门。一个更合理的写法是给T加上约束让调用方提供默认值、或者用类型参数表达“入参和出参的关系”function getValueT(key: string, defaultValue: T): T { const raw localStorage.getItem(key); if (raw null) return defaultValue; try { return JSON.parse(raw) as T; } catch { return defaultValue; } } // 调用方必须提供默认值T就有了依据 const count getValuenumber(count, 0);再比如后端分页接口往往会做一个通用的查询参数类型用泛型把“具体查询条件”和“分页参数”组合起来interface PageQueryT { page: number; pageSize: number; filters: T; } interface UserFilter { name?: string; role?: admin | user; } type UserPageQuery PageQueryUserFilter;泛型在这里成了“组合类型”的积木。关键是泛型参数一定要能追溯到业务含义而不是“先写个T再说”。如果团队里有人定义了function fT(x: any): T这样的签名大概率是没想清楚约束条件应该拉出来讨论一下。有一种情况我会刻意少用泛型组件内部复杂的状态类型。比如一个Table组件既要有数据项类型、又要有列类型、还要有排序字段类型三层泛型叠在一起调用方每个地方都要显式传参可读性会急剧下降。这时候可以考虑把太细的泛型拆成具名接口让使用者只关心最外层的那个泛型参数。类型设计的本质和API设计一样接口越小越清晰越好用。2. 前后端分离与全栈场景里的TypeScript实战如果只是把TS用在单一前端项目里你感受到的收益可能仅限于“写代码的时候有提示”。真正让TypeScript发挥威力的地方是前后端分离、甚至是全栈一体化的场景。这个场景下类型不再只是一份装饰而是一份可以跨端复用的“契约”。2.1 前后端共享类型一份契约两端校验前后端分离最常见的痛点是接口文档更新不及时、联调阶段字段对不上、后端改了字段名前端不知道。用TypeScript可以在很大程度上缓解这个问题——前提是你把类型定义做成共享的。共享的方式有两种我在不同规模的项目里都验证过第一种是monorepo内置共享包。用npm workspace或pnpm workspace管理把公共类型放进packages/shared前端和后端都通过依赖该包引入apps/ web/ # 前端应用 server/ # 后端应用 packages/ shared/ # 公共类型与常量第二种是发布一个内部npm包。适合多仓库、多团队协作的场景把API类型、枚举、常量统一发布版本号跟着契约走。这两种方式都不是银弹真正麻烦的是“后端改类型前端怎么感知”。我的建议是把共享类型当作唯一事实来源前端不允许绕过shared包自己再定义一份同名接口。如果前后端用的是同一个仓库配合CI检查改动字段时编译器会在另一个端报错等于把联调问题提前到了代码提交之前。这里有一个踩坑记录Shared包里的类型不要混入运行时实现。也就是说尽量只放interface、type、enum、常量这类纯类型和纯数据不要放axios实例、不要放业务工具函数。因为前后端的运行时依赖不同——前端是浏览器后端是Node——一旦混入运行时逻辑共享包就会被两端不同的环境扯来扯去最后要么包体积变大要么只能拆包。我见过一个团队把请求封装放进了shared包结果后端引入时因为缺少DOM API直接启动失败白白折腾了半天。2.2 NestJS搭配TypeScript依赖注入与类型推导NestJS是我个人很喜欢的Node端框架它本身用TypeScript编写依赖注入、装饰器、模块化的风格跟TypeScript的类型系统配合得相当好。但在实战里有几个地方容易写出“看似类型安全、其实没有类型”的代码。第一个是Service和Controller的类型推导。NestJS的依赖注入是通过装饰器实现的类型信息可以通过constructor(private readonly userService: UserService)完整推导出来这没问题。但很多人在Controller返回Response时喜欢手动标注一个很笼统的类型比如Promiseany这就把Service层辛苦建立起的类型安全都丢掉了。正确的做法是让Controller的返回类型直接引用Service层的返回类型或者显式声明DTOController(users) export class UserController { constructor(private readonly userService: UserService) {} Get() findAll(): PromiseUserInfo[] { return this.userService.findAll(); } }只要不写anyNestJS会帮你在Swagger文档、客户端代码生成等多个环节把类型信息传递下去。第二个是DTO的校验管道。NestJS里常配合class-validator在class-transformer转换后做运行时校验。由于TS的类型在运行时不存在DTO必须写成class而不是interface这一点和纯前端项目相反。很多前端转后端的人在这里栽过用interface定义DTO结果class-validator装饰器在运行时没有任何反射信息校验静默失效。// 错误示范interface会被编译掉class-validator拿不到元数据 export interface CreateUserDto { name: string; age: number; } // 正确示范必须用class import { IsString, IsInt, Min } from class-validator; export class CreateUserDto { IsString() name!: string; IsInt() Min(0) age!: number; }这个坑在项目里出现频率极高原因就是“interface在运行时不存在”。TypeScript的静态类型在编译后会被擦除class-validator依赖的是运行时元数据反射所以它必须要看到class才能工作。记住这一点以后在任何需要运行时校验的场景都不会走弯路。顺带一提由于interface会被编译清除nestjs/swagger的插件可以在编译期帮你生成OpenAPI元数据但前提也是DTO必须是class。第三个容易翻车的地方是NestJS的Body()装饰器配合泛型的问题。当你写Body() dto: CreateUserDto这个类型只在编译期有帮助运行时的dto其实是未经过class-transformer转换的纯JSON对象。如果你在validator里配置了whitelist: true那没问题如果依赖自动类型转换必须显式用Body()加管道否则dto的嵌套对象仍然不是类的实例。这一点每年都有团队踩坑建议在项目规范里明确标注。2.3 异步并发边界Promise.all与类型推导陷阱前后端项目里到处是异步。Promise.all是并发请求的常用手段但它的类型推导有个容易踩的坑。先看一段代码const [user, posts] await Promise.all([ fetchUser(id), fetchPostsByUser(id), ]);如果fetchUser返回PromiseUserfetchPostsByUser返回PromisePost[]那么这里user和posts的类型会被自动推导成User和Post[]看起来非常美好。但一旦某个Promise需要被条件性跳过或者请求数量是动态的问题就来了。比如在循环里多次发起请求很多人会顺手写Promise.all(items.map(item fetchItem(item)))。这里如果fetchItem返回PromiseItem推导结果是对的但如果异步函数内部抛错Promise.all会整体失败。当成组请求的业务中你需要的可能是“部分成功”而不是“要么全成功要么全失败”。这时可以用Promise.allSettledconst results await Promise.allSettled(items.map(fetchItem)); results.forEach((result) { if (result.status fulfilled) { // result.value: Item } else { // result.reason: unknown } });注意allSettled的失败分支里reason类型是unknown而不是any。这是TypeScript 4.1之后收紧的地方很多人刚升级时不太适应。处理方式是先收窄再使用function getErrorMessage(error: unknown): string { if (error instanceof Error) return error.message; return String(error); }这个unknown到具体类型的收窄过程在第五章面试部分还会看到类似的考点。现在先记住项目里涉及多个并发请求时先想清楚到底要“整体成功”还是“逐项成功”再去选方法类型系统会帮你兜底。另外还有一个和Promise相关的坑在async函数里用forEach配合async回调是等不到并发完成的因为forEach不会等Promise。这个和类型无关但实战里每天都有人踩顺便提醒一下。// 错误forEach不会等待异步操作完成 async function loadItems(items: number[]) { items.forEach(async (id) { const data await fetchItem(id); // 这里不会被await }); // 到这一行请求可能还没发完 } // 正确用for...of或者Promise.all async function loadItems(items: number[]) { for (const id of items) { const data await fetchItem(id); } }还有一个小经验在封装请求层时给Promise.all传数组之前先确认数组元素不会是Promisevoid或者undefined。如果从filter里过滤出来的数组元素类型可能带undefinedPromise.all会把undefined当空值处理类型上却不会报错。遇到这种情况需要先做类型收窄否则很容易出现“数组变短但类型没变”的错觉。3. 类型体操的实用边界什么时候该写什么时候别写网上关于TypeScript类型体操的内容铺天盖地从递归泛型到模板字符串类型看得人眼花缭乱。我必须说句实话真实业务项目里90%的场景用不到复杂的类型体操。类型体操的真正用途是给那些高复用、高抽象的工具函数和通用组件加固类型。如果一段类型代码需要花超过十分钟去理解除非它是核心公共库否则大概率是你的设计出了问题。3.1 解密Type[] [{}]这类看似奇怪的类型写法很多人在看开源项目时遇到过这样的代码interface PropsT {} { data: T[]; value?: T; }或者更直接的const items: Item[] [{}];先说第二种Item[] [{}]其实是一种默认值写法。Item[]是类型标注[{}]是初始值表示“初始化为一个空对象数组”。这在表单、表格这类场景里很常见——数据还没加载完成时先用空对象占位。但这里有个隐患如果Item要求某些必填字段[{}]根本不符合Item类型。TypeScript在给变量赋默认值时不会用数组元素的约束去严格检查对象的内部结构所以在某些情况下编译能过运行时却拿到一个缺字段的对象后面一访问就报错。比较稳妥的写法是用[]作为初始值而不是[{}]const items: Item[] [];那为什么还有人写[{}]一种原因是历史代码早期用过{}当任意值类似于“不知道是什么先占个位”另一种是想表达“一个对象的空壳”。如果你在项目里看到这种写法建议改掉——占位对象是最容易造成运行时错误的一类数据不如直接给一个空数组让UI层通过items.length控制渲染。再说回PropsT {}。这里的T {}是泛型默认参数表示“如果不传泛型就默认是空对象”。这是个兼容性写法常见于旧组件库。但在新版TypeScript里默认参数用unknown比{}更安全因为{}几乎匹配任何非null值约束很松。写公共组件时我一般用T unknown或者T Recordstring, never来表达“允许任意非空类型但我不做假设”。这两个默认值在行为上有微妙差异unknown对调用方来说更诚实表示“我也不知道是什么你自己看着办”{}则有可能在后续的extends判断里产生意外匹配。社区里关于{}和unknown的讨论已经吵了很多年如果你的代码里出现了T {}至少要想清楚自己到底想表达什么。3.2 declare global与命名空间全局扩展的代价declare global是TypeScript里少数能“污染”全局类型的语法平时不太会用到但整合第三方库、扩展环境变量时逃不开。最经典的场景是给Window接口追加自定义属性declare global { interface Window { __APP_VERSION__: string; __INITIAL_STATE__: Recordstring, unknown; } } export {};注意这里有个细节如果没有export {}这个文件会被当成全局脚本declare global反而会报错。因为TS里只有当文件是模块时declare global才有意义。这个坑很多人第一次写都会碰到。那什么时候真的需要declare global我总结了三类挂载到全局对象的第三方插件比如埋点SDK把实例挂在window.sensors上自定义环境变量像process.env.XXX这类在Node端扩展NodeJS.ProcessEnv给已有第三方模块补充类型当npm包本身没有类型定义且没有types包可用时。除了这些场景我不建议在业务代码里使用declare global。全局类型变更对全项目都有影响排查问题的时候很难定位“哪个文件的定义影响了这个类型”。能用局部类型、模块导入就尽量局部化。全局扩展的代价是隐式的当下看省事三个月后所有人都会忘记这个全局类型是从哪儿来的。命名空间namespace是另一个有争议的东西。在ES Module出现之前它承担“模块化”的职责现在日常项目里写业务代码基本用不到了。但写.d.ts声明文件时偶尔还是会看到namespace用于组织类型。我的态度是业务代码里不必刻意使用namespace全部用ES Module的导入导出即可写声明文件时如果确实要暴露多个相关类型可以用namespace做组织但要克制。还有一种场景是给UMD全局库写声明文件namespace几乎是必须的因为UMD库既有模块导出路径又有全局变量路径两套类型都要覆盖。3.3 条件类型、infer与可读性判断标准条件类型和infer是类型体操里比较有技术含量的部分它们能解决一些非常实际的问题。先看一个常见的需求获取一个函数返回值的类型。我们说ReturnTypeT可以做到它内部就用了infertype MyReturnTypeT extends (...args: any) any T extends (...args: any[]) infer R ? R : never;infer R相当于在条件类型里“抓取”一个占位类型然后把它暴露出来。这个能力很强可以写出一系列精巧的工具类型。但问题也随之而来代码的可读性会急剧下降。我在团队里定的一个判断标准是满足以下任何一个条件就说明你的类型体操写复杂了需要结合注释才能读懂的类型超过十行才能写出来的工具类型依赖递归但无法通过单元测试验证的类型为了一个“更优雅”的效果牺牲了上下游类型可读性的类型。那什么时候值得写我的经验是两类场景第一类是高复用的工具函数和公共类型比如把接口返回数据转成表单字段、把枚举值变成联合类型、给第三方库补齐缺失的类型。这类代码被很多文件引用值得投入精力做得精准。第二类是框架级别的泛型约束比如给React组件写高阶组件的类型封装、给状态管理库定义全局Store的类型。这类代码虽然难但它的收益是整个项目都生效的属于“做一次受益所有人”的投资。其它情况尤其是一次性业务代码我的建议是能用interface、type、联合类型解决绝不引入条件类型和infer。类型系统是为业务服务的不是用来炫技的。我见过一个团队把很简单的onClick回调类型写成了七八层条件类型嵌套最后新来的同事连“点击事件应该传什么参数”都看不懂这就是典型的为了抽象而抽象。再说一个反直觉的经验条件类型和分配律之间的关系是类型体操最容易出错的地方。T extends U ? X : Y在T是联合类型时会发生“分配”也就是对联合类型的每个成员分别判断最后再组合。这个行为有时是好事比如Exclude就是这么实现的有时是坏事比如你只是想整体判断T extends U却得到了“每个成员分别判断再合并”的结果。要关掉分配可以用方括号把T包起来type NoDistributeT [T] extends [any] ? true : false;这种细节写起来容易但读起来更难。如果你的团队里没有至少两个人能看懂这段代码那它就不应该出现在业务代码里放到工具类型库里并配上充分的注释更合适。4. tsconfig工程化与版本迁移实战中最容易翻车的细节类型写得再漂亮编译器配置不对项目照样跑不起来。tsconfig是每个TypeScript项目都绕不开的配置文件但绝大多数人只会复制粘贴模板出了问题才去翻文档。这一节我说几个实战里踩过的坑尤其是升级TypeScript版本时会遇到的。4.1 strict全家桶里真正影响代码写法的开关strict: true是一站式开关它同时打开了一系列严格性选项。但有个细节很多团队的tsconfig是历史遗留的这个项目的strict可能一开始就是false后来加了strictNullChecks: true再后来补了别的配置乱成一锅粥。比较推荐的方式是直接strict: true然后显式关闭个别难以迁移的项并标注TODO{ compilerOptions: { strict: true, noImplicitAny: true, strictNullChecks: true, strictFunctionTypes: true, strictPropertyInitialization: true, noUncheckedIndexedAccess: true } }这里我列出几个容易影响代码写法的开关开关作用常见影响strictNullChecks检查null和undefined的赋值与访问打开后stringnoImplicitAny禁止隐式any参数、变量没有类型标注时直接报错strictFunctionTypes函数类型参数逆变检查影响回调函数的事件对象、setState函数等strictPropertyInitialization类属性必须有初始化或明确赋值构造函数里没赋值的属性会报错noUncheckedIndexedAccess索引访问自动加undefined数组和对象取值后要判空最影响日常代码重点说noUncheckedIndexedAccess。它是我觉得最被低估的一个开关。没有它的时候取数组元素或对象属性的类型不会被自动加上undefined所以你可能写出arr[0].toFixed()——编译器不报错但运行时arr为空数组就崩了。打开这个开关后arr[0]的类型是number | undefined强制你先做判断。这在处理接口返回的列表数据时极其有用。但也要知道它的代价代码里会出现很多“明明不为空但类型带着undefined”的情况需要写一堆非空断言!或者先做一次长度判断再取值。我的建议是项目早期就打开它养成“先判空再使用”的习惯如果是老项目迁移逐文件处理会比较痛苦。有个折中方案只对特定代码区域打开// ts-expect-error但说实话这种注释多了比报错更难看。如果想在数组场景下避免大量undefined干扰可以考虑用for...of代替索引访问类型推导会更友好。4.2 从baseUrl弃用看TypeScript配置演进最近不少项目升级TypeScript之后控制台会冒出类似这样的警告选项“baseUrl”已弃用并将停止在 TypeScript 7.0 中运行。请指定“compilerOptions.paths”来替代。这其实不是bug而是TypeScript官方在逐步清理配置项。baseUrl原本用于设置非相对路径模块的解析基准目录配合paths一起做路径别名。但实际使用中baseUrl对模块解析的影响往往超出预期而且现在Node的package.json里已经支持exports、imports字段baseUrl的很多功能都被取代了。迁移方式很简单。如果你现在的tsconfig长这样{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }改成这样{ compilerOptions: { paths: { /*: [./src/*] } } }关键点是paths里的路径从src/*改成./src/*相对路径解析不再依赖baseUrl。这样即使有多个tsconfig每个都基于自己的位置解析不会互相干扰。升级到TypeScript 5.x时如果用的是moduleResolution: node也会收到提示建议切换为moduleResolution: bundler或node16具体取决于你的构建工具。这里有一个很容易被忽略的连带影响paths改成相对路径之后某些IDE插件可能不会立刻感知别名变化需要重启TypeScript语言服务。如果项目里有多个tsconfig比如前端和后端分开每个tsconfig里都要有自己对应的paths否则其中一个端的导入会失效。还有一点如果项目用了references做项目引用Project Referencespaths在composite项目里需要配合rootDir和outDir一起调整否则编译产物的相对位置会错乱。我的经验是这类配置迁移最好在单独一次提交里完成不要和业务改动混在一起。因为一旦出现“本地能跑、CI挂了”的情况排查范围就大了。4.3 路径别名、构建产物与工具链配置路径别名虽然好用但要关注它对构建工具的影响。tsconfig里配好了pathsTypeScript编译器知道/指向哪里但编译后的JavaScript不会自动改写这些路径真正执行代码的是打包器Vite、Webpack、Rollup等所以你还得在构建工具里配置同名的resolve.alias。如果没有同步配置就会出现一个常见的症状编辑器里代码提示和跳转都正常但一跑构建就报Cannot find module /xxx。// vite.config.ts 里对应的配置 import path from node:path; export default defineConfig({ resolve: { alias: { : path.resolve(__dirname, src), }, }, });另外如果项目还会产出给Node运行的构建产物比如前端项目的SSR服务、或者纯Node的后端要注意tsc编译后的输出目录结构。module、moduleResolution、outDir这些配置会影响产物里路径的解析方式路径别名在这个场景下尤其容易搞错。一个比较稳妥的方案是开发期用打包器的alias编译期用tsc的paths保证tsconfig和构建工具里的alias配置完全一致。还有一种做法是干脆不用路径别名统一用相对路径。小团队、小项目可以考虑省去一套配置。但项目大起来后相对路径../../../../utils的可读性简直灾难所以路径别名还是值得配置的。不过如果配置项已经让人头疼到想放弃我建议先从“把公共代码抽离到独立包”开始让别名需求自然变少而不是硬撑着维护一套很容易出错的alias体系。还有一个和tsconfig相关的翻车点esModuleInterop和allowSyntheticDefaultImports。这两个开关很多项目里是默认打开的但如果你的项目从CommonJS切换到ESModule过程中这两个配置没同步到新tsconfig就会出现“默认导入突然报错”的情况。比如你习惯了写import express from express但esModuleInterop: false时这种写法会报错得改成import * as express from express。这类问题不像路径别名那样直观但一旦出现几乎每个人都要花时间查一遍文档。5. 面试高频题背后的实战思维最后聊一聊面试。TypeScript相关的面试题表面上考语法实际上考的是候选人对类型系统的理解深度和工程判断力。热搜里“typescript面试”“前后端分离项目实战”“typescript nestjs”这些词出现频率很高说明大家都在关注面试怎么准备。我把我面试别人时最常问的几个方向整理一下并解释每个问题背后的考察点。5.1 类型收窄与自定义类型守卫一道经典题typeof、instanceof、Array.isArray、in、自定义类型守卫有什么区别各自用在什么场景其实答案很清晰typeof用在基础类型上的收窄比如typeof x string但不能细分数组和对象instanceof用于判断类实例比如error instanceof ErrorArray.isArray专门判断数组in用于判断对象属性是否存在比如message in obj自定义类型守卫arg is Type适合自定义复杂逻辑。考察点不在于你是否背出这五个而在于你是否理解“为什么需要它们”。类型收窄是TypeScript在静态类型和JavaScript动态运行之间搭的一座桥。运行时并没有类型信息收窄的本质是通过条件的真假把变量的类型从“宽”变成“窄”。面试里我更喜欢追问一个实际场景接口返回的数据可能是string也可能是{ message: string }你如何处理一个常见答案function normalizeError(input: unknown): string { if (typeof input string) return input; if (input typeof input object message in input) { return String(input.message); } return 未知错误; }这里用到了typeof、in还有对unknown的层层收窄。能把这个函数在纸上写清楚、且解释每一步为什么这样做的人基本上对类型收窄是真正理解了的。注意一个细节in操作符的判断顺序很关键必须先判断input是对象再判断message in input否则在null上直接用in会抛运行时错误。还有一个我自己在工作中经常用到的场景从后端返回的数据里过滤出“有某个可选字段”的元素。很多人会习惯性写item.field ...但更好的做法是用自定义类型守卫把这个逻辑抽取出来interface WithVersion { version: string; } function hasVersion(item: unknown): item is WithVersion { return typeof item object item ! null version in item typeof (item as any).version string; }这样在items.filter(hasVersion)之后结果数组的元素类型就不再携带undefined或unknown后面访问.version不需要再收窄。这个小技巧既干净又实用能体现一个人对类型收窄的掌握程度。5.2 从内置工具类型看面试考什么面试里还常考Partial、Required、Pick、Exclude、ReturnType等工具类型。有一些面试官会直接问“手写实现”。如果只看答案你会发现它们都很短比如type MyPartialT { [P in keyof T]?: T[P]; }; type MyPickT, K extends keyof T { [P in K]: T[P]; }; type MyExcludeT, U T extends U ? never : T; type MyReturnTypeT extends (...args: any) any T extends (...args: any[]) infer R ? R : never;这些题目真正的考察点不是记忆而是你有没有理解映射类型[P in keyof T]、条件类型T extends U ? ... : ...、infer、keyof、索引访问T[P]这些基础概念。能写出来是第一步能讲清楚“为什么Exclude用条件类型是天然满足的”“为什么Pick需要extends keyof T约束”才是加分项。我自己在面试时通常会把题目稍微升级比如让候选人实现一个DeepReadonly或者NonNullableFields考察的是在真正复杂的类型需求面前候选人的思考路径是否清晰、是否会借助工具类型、是否能简化问题。这道题没有标准答案但能反映一个人的类型功底上限。DeepReadonly这个例子很有意思它看起来很简单但真写起来涉及递归、映射类型和边界条件type DeepReadonlyT T extends (infer U)[] ? ReadonlyArrayDeepReadonlyU : T extends object ? { readonly [K in keyof T]: DeepReadonlyT[K] } : T;能把这个写对的人至少说明理解了数组、对象、函数这三种类型在递归处理时的不同走向。但面试官更想听的可能是你会不会先问一句“业务里真的需要深只读吗”因为大多数场景下ReadonlyT浅层就够用了深度递归反而增加编译负担。这种“工程判断优先”的回答比闷头写完代码更能加分。5.3 答题思路面试官其实想听你讲“为什么”很多候选人背题背得很熟但一被问到“你实际项目里这样写过吗当时为什么这么设计”就卡壳。所以近年面试越来越看重“实战思维”。比如面试官问interface和type有什么区别光背“interface可以合并、type不行interface用于对象类型、type更灵活”这些答案往往是基础分。但如果你能结合实战说在写公共库或组件时我倾向用interface暴露API因为它支持声明合并用户可以在自己的项目里用declare module扩展第三方库的接口在定义联合类型、条件类型、工具类型这种“计算出来的类型”时必须用type在同一个文件里为了避免认知混乱我会约定一个规则对象结构用interface联合、交叉、计算类型用type。这个回答就比单纯背区别要立体得多因为它涉及了“在真实项目中如何选择”的工程判断。面试官能从中看到你确实在项目里思考过这些问题而不是考前突击背了几道题。再比如之前提过的unknown和any的比较如果你能补充上“前端项目里解构JSON数据时常用as断言但as是最后手段能用类型守卫就不用as”这类经验面试官一眼就能看出来你是不是真的在实战里被坑过。我面试过不少人能主动提到“类型守卫可以减少as的使用”的候选人基本都在代码评审里被人怼过那种经验是装不出来的。我在实际带团队时还发现一个规律真正TypeScript用得好的开发者代码review时很少纠结语法更多是在讨论“这个状态的类型设计得是否合理”“这里的泛型约束是不是过宽”“这个类型断言有没有可能掩盖一个空值bug”。面试的时候如果你能把话题引导到这些方向就已经赢了大多数人。面试不是背诵比赛是在一个半小时里让面试官相信把代码交给你你不会把类型系统用歪也不会在类型上栽跟头。带着这样的心态去准备效果比刷一百道题好得多。最后再分享一个我自己的小习惯遇到拿不准的类型写法我会先去项目里搜一下有没有现成案例没有的话写一个极简的repro文件验证再落地。TypeScript的类型系统很强大但它的复杂度和业务复杂度是两回事。实战里真正的高手不是会写最复杂类型的人而是能把复杂问题用简单类型表达清楚的人。
阅读完成 · 觉得有帮助?