首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >那个说"TypeScript是多余的"的同事,昨晚又在改bug到凌晨

那个说"TypeScript是多余的"的同事,昨晚又在改bug到凌晨

作者头像
前端达人
发布2026-03-12 15:32:08
发布2026-03-12 15:32:08
50
举报
文章被收录于专栏:前端达人前端达人

先说个真事

上个月组里来了个新人,工作两年,简历上写着"精通TypeScript"。

第一天他就跟我说:"TypeScript就是JavaScript加个类型标注,有啥难的?"

我笑了笑没说话。

一周后,他提交的代码炸了。测试同学过来找他:"为什么这个订单的金额显示NaN?"

他自己看了半天代码,说:"不应该啊,我都写了类型的。"

我过去看了一眼:

代码语言:javascript
复制
function calculateTotal(order: any) {
  return order.items.reduce((sum, item) => sum + item.price, 0);
}

看出问题了吗?order.items 可能是 undefined,直接崩了。

"你不是写了类型吗?" 测试同学问。

"我写了啊,你看,order: any..." 他突然意识到了什么。

对,any 就是"我放弃治疗"的意思。编译器看到 any 就不管了,你写啥都行,能不能跑就看运气了。

这就是我想说的:90%的人以为自己在用TypeScript,其实只是在用"带类型注释的JavaScript"。

今天我们就聊聊那些真正能救命的TypeScript技巧——每个都是我或者我同事踩过坑之后的血泪经验。

一、类型定义到处复制?你需要知道"类型也能用下标"

我遇到的问题

去年做一个用户中心项目,后端定义了用户的数据结构。

前端各个组件都需要用到用户的某些字段,于是我就到处复制类型定义:

代码语言:javascript
复制
// 用户的完整信息
interface User {
  id: string;
  name: string;
  email: string;
  profile: {
    age: number;
    bio: string;
    avatar: string;
    address: {
      province: string;
      city: string;
      street: string;
    }
  }
}

// 个人资料组件需要profile
interface UserProfile {
  age: number;
  bio: string;
  avatar: string;
  address: {
    province: string;
    city: string;
    street: string;
  }
}

// 地址组件需要address
interface UserAddress {
  province: string;
  city: string;
  street: string;
}

看起来没啥问题对吧?

直到有一天,后端说:"我们给 address 加了个 zipCode 字段。"

我:😱

我得去找所有用到 address 的地方,一个个改。改了十几个文件,漏了两个,又出bug了。

后来学到的方法

其实根本不用复制,可以直接"索引"出来:

代码语言:javascript
复制
interface User {
  id: string;
  name: string;
  email: string;
  profile: {
    age: number;
    bio: string;
    avatar: string;
    address: {
      province: string;
      city: string;
      street: string;
    }
  }
}

// 直接用方括号"取出"类型,就像访问对象属性一样
type UserProfile = User['profile'];
type UserAddress = User['profile']['address'];
type City = User['profile']['address']['city'];  // 甚至可以一直取下去

这就是 Indexed Access Types(索引访问类型)。

听起来高大上,其实就是:"类型也可以像对象一样用方括号访问"。

现在后端改了 address,我只需要改 User 这一个地方,其他地方自动就同步了。

为什么有用?

  • 单一数据源,改一处生效
  • 不会因为复制粘贴导致类型不一致
  • 重构的时候省心,编译器会告诉你哪里不对

二、别用 any 了,学会自己教TypeScript识别类型

踩过的坑

以前写代码,遇到不确定类型的数据,我就直接 any

代码语言:javascript
复制
function processData(data: any) {
  console.log(data.name);  // 能跑,但可能炸
}

"能跑"是能跑,但 data 到底是个啥?有没有 name 这个属性?天知道。

有一次接了个后端接口,返回的数据可能是用户信息,也可能是错误信息:

代码语言:javascript
复制
const response = await fetch('/api/user');
const data = await response.json();  // data的类型是any

// 我直接用了
console.log(data.name);  // 如果是错误信息,这里就炸了

更好的办法:Type Guard(类型守卫)

后来我学会了先"检查"一下:

代码语言:javascript
复制
// 定义一个检查函数
function isUser(data: any): data is User {
return (
    data &&
    typeof data === 'object' &&
    typeof data.id === 'string' &&
    typeof data.name === 'string'
  );
}

// 使用的时候先检查
const response = await fetch('/api/user');
const data = await response.json();

if (isUser(data)) {
// 在这个if里面,TypeScript知道data是User类型
console.log(data.name);  // ✅ 安全
console.log(data.email); // ✅ 有提示
} else {
console.error('数据格式不对');
}

关键在于 data is User 这个写法。

这是在告诉TypeScript:"如果这个函数返回true,那data就是User类型。"

听起来有点绕?换个说法:

你在教TypeScript一个判断规则——"怎么知道一个东西是User"。

之前TypeScript不知道怎么判断,现在你教它了,它就能在代码里自动推导类型了。

真实收益:

上个月重构了一个老项目,把所有 any 改成了Type Guard,发现了17个潜在的bug,全是"可能访问undefined的属性"这种问题。

三、再也不怕漏掉case:强制你处理所有情况

遇到的问题

写一个订单状态处理函数:

代码语言:javascript
复制
type OrderStatus = 'pending' | 'paid' | 'cancelled';

function getStatusText(status: OrderStatus) {
switch (status) {
    case'pending':
      return'待支付';
    case'paid':
      return'已支付';
    case'cancelled':
      return'已取消';
  }
}

看起来完美对吧?三个状态都处理了。

某天产品经理说:"加个'退款中'状态吧。"

后端改了:

代码语言:javascript
复制
type OrderStatus = 'pending' | 'paid' | 'cancelled' | 'refunding';

然后你的代码编译照样通过,因为语法上没问题。

结果上线后,用户点了退款,页面啥反应都没有——因为你忘了处理 refunding

正确的做法:Exhaustive Checking

加一行代码就能避免:

代码语言:javascript
复制
function getStatusText(status: OrderStatus) {
switch (status) {
    case'pending':
      return'待支付';
    case'paid':
      return'已支付';
    case'cancelled':
      return'已取消';
    default:
      // 🔥 关键在这里
      const _exhaustive: never = status;
      return _exhaustive;
  }
}

现在如果你加了 refunding 但忘了处理,编译就报错:

代码语言:javascript
复制
Type 'refunding' is not assignable to type 'never'.

为什么有用?

  • 如果所有case都处理了,走到 default 的时候,status 的类型就是 never(永远不会走到这里)
  • 如果漏了某个case,那个值就会跑到 default,类型对不上就报错

用人话说:你在 default 放了个"绝对不会执行"的代码,如果真的执行了,说明你漏了东西。

我现在写枚举处理都会加这一行,救了我好多次。

四、既要类型检查,又要保留具体值?用 satisfies

遇到的矛盾

做主题系统的时候,有个配置对象:

代码语言:javascript
复制
const theme = {
  primary: '#1890ff',
  success: '#52c41a',
  error: '#ff4d4f'
};

现在有两个需求:

  1. 要类型检查:确保所有value都是string
  2. 要保留具体值theme.primary 的类型是 '#1890ff' 而不是 string

为什么要保留具体值?因为后面要根据颜色值生成深浅色变体,如果类型是 string 就推导不出来了。

以前的办法都不完美:

代码语言:javascript
复制
// 方法1:加类型注解
type Theme = Record<string, string>;
const theme: Theme = {
  primary: '#1890ff',  // theme.primary的类型变成了string,丢失了具体值
  success: '#52c41a',
  error: '#ff4d4f'
};

// 方法2:用as const
const theme = {
  primary: '#1890ff',  // theme.primary的类型是'#1890ff',但...
  success: '#52c41a',
  error: '#ff4d4f'
} asconst;
// 整个对象变readonly了,不能改了

完美解决:satisfies

TypeScript 4.9加的新功能:

代码语言:javascript
复制
type Theme = Record<string, string>;

const theme = {
  primary: '#1890ff',
  success: '#52c41a',
  error: '#ff4d4f'
} satisfies Theme;

// 完美:
// ✅ 编译器会检查是否符合Theme类型
// ✅ theme.primary的类型是 '#1890ff'(保留了具体值)
// ✅ theme不是readonly,可以修改

satisfies 的意思就是:"这个对象满足Theme类型,但不要把它变成Theme类型"。

听起来绕,简单说就是:既要检查,又不要改变原来的类型

五、从类型里"提取"信息:神奇的 infer

实际场景

项目里有很多API函数:

代码语言:javascript
复制
async function getUserInfo(id: string): Promise<UserInfo> {
  // ...
}

async function getOrderList(page: number): Promise<OrderList> {
  // ...
}

现在问题来了:我在其他地方想用 UserInfo 这个类型,但我不想重复定义,怎么从函数里"提取"出来?

infer 自动推导

代码语言:javascript
复制
// 定义一个工具类型
type GetReturnType<T> = T extends (...args: any) => Promise<infer R> ? R : never;

// 使用
type UserInfo = GetReturnType<typeof getUserInfo>;  // 自动推导出UserInfo
type OrderList = GetReturnType<typeof getOrderList>; // 自动推导出OrderList

infer 是什么意思?

简单说就是:"我不知道这里是什么类型,TypeScript你帮我推导一下,推导出来的结果叫R"。

再举个例子,提取Promise的返回值:

代码语言:javascript
复制
type UnwrapPromise<T> = T extends Promise<infer R> ? R : T;

type A = UnwrapPromise<Promise<string>>;  // A是string
type B = UnwrapPromise<Promise<number>>;  // B是number
type C = UnwrapPromise<boolean>;          // C是boolean(不是Promise,原样返回)

执行过程(以 Promise<string> 为例):

  1. TypeScript看到 T extends Promise<infer R>
  2. 发现 T 确实是 Promise<...> 的形式
  3. 推导出里面的类型是 string,把它赋给 R
  4. 返回 R(也就是 string

更实用的例子:提取数组元素类型

代码语言:javascript
复制
type ArrayElement<T> = T extends (infer R)[] ? R : never;

type Numbers = ArrayElement<number[]>;  // Numbers是number
type Strings = ArrayElement<string[]>;  // Strings是string

上周我用这个重构了整个API类型系统,原来要手写的几十个类型定义,现在都自动推导了。

六、函数参数太多容易搞混?给元组加标签

踩过的坑

以前定义一个坐标类型:

代码语言:javascript
复制
type Point = [number, number];

function drawLine(start: Point, end: Point) {
  const [x1, y1] = start;
  const [x2, y2] = end;
  // ...
}

drawLine([0, 0], [100, 50]);  // 哪个是x,哪个是y?搞不清

问题是:调用的时候,[0, 0] 到底哪个是x,哪个是y?记不住啊。

尤其是参数多了:

代码语言:javascript
复制
type Rect = [number, number, number, number];

function drawRect(rect: Rect) {
  // rect是 [x, y, width, height] 还是 [left, top, right, bottom]?
  // 鬼知道
}

Labeled Tuples(带标签的元组)

TypeScript 4.0加的功能:

代码语言:javascript
复制
type Point = [x: number, y: number];

function drawLine(start: Point, end: Point) {
  // ...
}

// 现在鼠标悬停的时候,编辑器会显示:
// start: [x: number, y: number]

更明显的例子:

代码语言:javascript
复制
type Rect = [x: number, y: number, width: number, height: number];

function drawRect(rect: Rect) {
  const [x, y, width, height] = rect;  // 一目了然
}

好处:

  • 不会搞混参数顺序
  • 编辑器有更好的提示
  • 代码更容易理解

上个月重构地图系统的时候,把所有坐标类型都加了标签,新人上手快了很多

七、批量生成类型:Mapped Types

遇到的需求

做表单系统,每个字段都需要验证函数:

代码语言:javascript
复制
type FormData = {
  username: string;
  email: string;
  password: string;
  age: number;
};

// 需要为每个字段定义验证函数
type FormValidators = {
  username: (value: string) =>boolean;
  email: (value: string) =>boolean;
  password: (value: string) =>boolean;
  age: (value: number) =>boolean;
};

手写?30个字段写到吐血。

Mapped Types自动生成

代码语言:javascript
复制
type Validators<T> = {
  [K in keyof T]: (value: T[K]) => boolean
};

type FormValidators = Validators<FormData>;
// 自动生成了!

怎么理解?

  1. keyof T 拿到所有key:'username' | 'email' | 'password' | 'age'
  2. [K in keyof T] 遍历每个key
  3. T[K] 拿到这个key对应的类型
  4. 组装成 (value: T[K]) => boolean

再举个例子,生成"所有字段都可选"的类型:

代码语言:javascript
复制
type MyPartial<T> = {
  [K in keyof T]?: T[K]
};

type User = {
  id: string;
  name: string;
  email: string;
};

type PartialUser = MyPartial<User>;
// 相当于:
// {
//   id?: string;
//   name?: string;
//   email?: string;
// }

更酷的:修改key的名字

代码语言:javascript
复制
type Getters<T> = {
  [K in keyof T as`get${Capitalize<K & string>}`]: () => T[K]
};

type User = {
  name: string;
  age: number;
};

type UserGetters = Getters<User>;
// 结果:
// {
//   getName: () => string;
//   getAge: () => number;
// }

上周用这个给200多个数据模型自动生成了getter方法的类型定义,爽歪歪。

八、强制命名规范:Template Literal Types

真实场景

组里规定:所有事件处理函数必须以 handle 开头。

但总有人忘记,代码review的时候很烦。

后来我用类型强制了:

代码语言:javascript
复制
type EventHandler = `handle${Capitalize<string>}`;

function registerHandler(name: EventHandler, handler: Function) {
  // ...
}

registerHandler('handleClick', () => {});   // ✅ 通过
registerHandler('handleSubmit', () => {});  // ✅ 通过
registerHandler('onClick', () => {});       // ❌ 编译报错
registerHandler('click', () => {});         // ❌ 编译报错

现在谁要是不按规范写,编译都过不了。

更实用的:API路由校验

代码语言:javascript
复制
type ApiRoute = `/api/${string}`;

function callApi(url: ApiRoute) {
  fetch(url);
}

callApi('/api/users');      // ✅
callApi('/api/orders/123'); // ✅
callApi('/users');          // ❌ 编译报错,必须以/api/开头

CSS类名规范(BEM):

代码语言:javascript
复制
type BEMClass = `${string}__${string}` | `${string}__${string}--${string}`;

function addClass(className: BEMClass) {
  // ...
}

addClass('button__icon');             // ✅ block__element
addClass('button__icon--active');     // ✅ block__element--modifier
addClass('button-icon');              // ❌ 不符合BEM规范

上个月用这个规范了整个组件库的类名,**CSS命名相关的bug少了90%**。

九、过滤类型:Distributive Conditional Types

需求场景

有个联合类型,想过滤掉 nullundefined

代码语言:javascript
复制
type MixedType = string | number | null | undefined | boolean;

// 想要得到:string | number | boolean

用分配条件类型

代码语言:javascript
复制
type NonNullable<T> = T extends null | undefined ? never : T;

type CleanType = NonNullable<MixedType>;
// 结果:string | number | boolean

为什么叫"分配"?

因为TypeScript会自动把联合类型"拆开",一个个处理:

代码语言:javascript
复制
执行过程:
1. string extends null | undefined ? never : string  → string
2. number extends null | undefined ? never : number  → number
3. null extends null | undefined ? never : null      → never
4. undefined extends null | undefined ? never : undefined → never
5. boolean extends null | undefined ? never : boolean → boolean

最后把结果合并:string | number | boolean

更实用的:提取某种类型

代码语言:javascript
复制
type ExtractString<T> = T extends string ? T : never;

type Mixed = string | number | boolean | string;
type OnlyStrings = ExtractString<Mixed>;  // string

过滤掉函数类型:

代码语言:javascript
复制
type NonFunction<T> = T extends Function ? never : T;

type Mixed = string | number | (() => void) | boolean;
type NoFunctions = NonFunction<Mixed>;  // string | number | boolean

这个在处理复杂数据结构的时候特别有用。

十、配置对象别乱改:as constreadonly

血泪教训

以前有个全局配置:

代码语言:javascript
复制
const config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retries: 3
};

某天调试的时候,我改了 timeout

代码语言:javascript
复制
config.timeout = 1000;  // 调试用

然后忘了改回来,直接提交了。

结果线上所有请求超时时间变成1秒,大量接口超时。

as const 锁死

代码语言:javascript
复制
const config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retries: 3
} as const;

config.timeout = 1000;  // ❌ 编译报错:Cannot assign to 'timeout' because it is a read-only property

现在谁都改不了,想改也得先去掉 as const,不会不小心改了。

另一个好处:保留字面量类型

代码语言:javascript
复制
const directions = ['north', 'south', 'east', 'west'] as const;

type Direction = typeof directions[number];
// Direction = 'north' | 'south' | 'east' | 'west'
// 不是 string

路由配置的例子:

代码语言:javascript
复制
const routes = [
  { path: '/home', component: 'Home' },
  { path: '/about', component: 'About' },
  { path: '/contact', component: 'Contact' }
] asconst;

type RoutePath = typeof routes[number]['path'];
// RoutePath = '/home' | '/about' | '/contact'

function navigate(path: RoutePath) {
// 只能用定义过的路由
}

navigate('/home');     // ✅
navigate('/profile');  // ❌ 编译报错

现在所有配置文件我都用 as const,再也不担心被人误改了。

十一、处理嵌套数据:Recursive Types(递归类型)

遇到的问题

要定义JSON数据的类型,但JSON可以无限嵌套:

代码语言:javascript
复制
const data = {
  name: 'John',
  age: 30,
  friends: [
    {
      name: 'Jane',
      age: 28,
      friends: [
        {
          name: 'Bob',
          // 可以一直嵌套下去...
        }
      ]
    }
  ]
};

怎么定义类型?

递归类型定义

代码语言:javascript
复制
type Json = 
  | string
  | number
  | boolean
  | null
  | Json[]                    // 数组里可以是Json
  | { [key: string]: Json };  // 对象的值也可以是Json

const data: Json = {
  name: 'John',
  age: 30,
  friends: [
    {
      name: 'Jane',
      age: 28,
      friends: [/* 无限嵌套,类型都对 */]
    }
  ]
};

关键在于 Json 的定义里引用了自己,就像递归函数一样。

树形菜单的例子:

代码语言:javascript
复制
type MenuItem = {
  id: string;
  label: string;
  icon?: string;
  children?: MenuItem[];  // 递归:children也是MenuItem数组
};

const menu: MenuItem = {
  id: '1',
  label: '系统设置',
  children: [
    {
      id: '1-1',
      label: '用户管理',
      children: [
        {
          id: '1-1-1',
          label: '添加用户'
        },
        {
          id: '1-1-2',
          label: '用户列表'
        }
      ]
    },
    {
      id: '1-2',
      label: '权限管理'
    }
  ]
};

评论回复的例子:

代码语言:javascript
复制
type Comment = {
  id: string;
  content: string;
  author: string;
  replies?: Comment[];  // 评论下面可以有回复,回复下面还能有回复
};

const comments: Comment = {
  id: '1',
  content: '这篇文章写得不错',
  author: 'User1',
  replies: [
    {
      id: '2',
      content: '确实',
      author: 'User2',
      replies: [
        {
          id: '3',
          content: '+1',
          author: 'User3'
        }
      ]
    }
  ]
};

上个月做组织架构树的时候,用递归类型定义,无论多少层级都能完美支持。

最后说说我的感受

以前我也觉得TypeScript麻烦——

"写个类型定义比写业务代码还费劲" "编译报错一堆,改半天" "有这时间我代码都写完了"

但现在回过头看,那些"麻烦"的类型定义,帮我避免了无数次线上事故。

上周重构一个老项目,把核心模块加了完善的类型定义,编译器直接帮我找出了23个潜在bug——全是"可能访问undefined"、"遗漏case处理"这种问题。

如果没有TypeScript,这些bug只能等用户遇到了再来报。

TypeScript的价值不是让你写得更快,而是让你睡得更安稳。

不用担心改了一个地方其他地方炸了,不用担心遗漏了某个状态处理,不用担心配置被人乱改。

尤其是大型项目、多人协作的时候,TypeScript就是你的安全网。

快速回顾

技术点

解决什么问题

记住这句话

Indexed Access Types

类型定义到处复制

类型也能用方括号访问

Type Guards

any满天飞不安全

教TypeScript怎么识别类型

Exhaustive Checking

忘记处理某个case

让编译器强制你处理所有情况

satisfies

既要检查又要保留字面量

满足类型但不变成那个类型

infer

从类型中提取信息

让TypeScript帮你推导

Labeled Tuples

元组参数容易搞混

给元组的每个位置加标签

Mapped Types

批量生成类型

遍历key生成新类型

Template Literal Types

强制命名规范

字符串也能有类型约束

Distributive Conditional Types

过滤联合类型

自动拆开联合类型处理

as const & readonly

防止配置被改

锁死数据保留字面量

Recursive Types

处理嵌套结构

类型定义里引用自己

你的项目里用到这些技巧了吗? 或者遇到过哪些本可以用TypeScript避免的坑?评论区聊聊。

觉得有用的话,点个赞、转给你的同事,让更多人少踩坑、少加班。

关注「前端达人」,每周分享实用的前端开发经验,帮你写出更好维护、更少bug的代码。

下期见!👋

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2025-12-02,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端达人 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 先说个真事
  • 一、类型定义到处复制?你需要知道"类型也能用下标"
    • 我遇到的问题
    • 后来学到的方法
  • 二、别用 any 了,学会自己教TypeScript识别类型
    • 踩过的坑
    • 更好的办法:Type Guard(类型守卫)
  • 三、再也不怕漏掉case:强制你处理所有情况
    • 遇到的问题
    • 正确的做法:Exhaustive Checking
  • 四、既要类型检查,又要保留具体值?用 satisfies
    • 遇到的矛盾
    • 完美解决:satisfies
  • 五、从类型里"提取"信息:神奇的 infer
    • 实际场景
    • 用 infer 自动推导
  • 六、函数参数太多容易搞混?给元组加标签
    • 踩过的坑
    • Labeled Tuples(带标签的元组)
  • 七、批量生成类型:Mapped Types
    • 遇到的需求
    • Mapped Types自动生成
  • 八、强制命名规范:Template Literal Types
    • 真实场景
  • 九、过滤类型:Distributive Conditional Types
    • 需求场景
    • 用分配条件类型
  • 十、配置对象别乱改:as const 和 readonly
    • 血泪教训
    • 用 as const 锁死
  • 十一、处理嵌套数据:Recursive Types(递归类型)
    • 遇到的问题
    • 递归类型定义
  • 最后说说我的感受
  • 快速回顾
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档