← 返回博客
·前端工具

TypeScript 类型体操:那些让我熬秃了头的真实场景

分享几个实际项目中用过的 TypeScript 类型技巧,从路径访问类型到动态表单验证,结合代码示例讲清楚每个场景的踩坑经验。

#TypeScript#类型系统#前端

# TypeScript 类型体操:那些让我熬秃了头的真实场景

这两年写 TypeScript 最大的感悟是:这玩意儿入门门槛低,但想用好,真的得花时间去研究类型系统。很多人写 ts 就是 any 走天下,然后抱怨"TypeScript 有什么好用的"。说白了,是没摸到门道。

今天聊几个我实际项目中用过的类型技巧,不是那种教科书式的"什么是联合类型",是实打实解决过问题的方案。

场景一:从 API 响应里抽类型,但不想把所有字段都搬过来

做过中台系统的应该都有这个痛点:后端接口返回的数据对象嵌套七八层,你不可能一个个字段去定义 interface。常见做法是直接 type ApiResponse = typeof response,但问题是这里面的字段都是只读的,而且万一后端加了字段,你的类型定义不会自动更新。

我的解法是写一个工具类型,只抽特定层级的字段:

type PickNested<T, K extends keyof T> = {

[P in K]: T[P]

};

type UserPreview = PickNested<UserFullResponse, 'id' | 'name' | 'avatar'>;

但这个有个问题——如果我要的是嵌套很深的那层字段呢?比如 response.data.user.info.nickname。我写过一个小巧的路径访问类型:

type PathValue<T, P extends string> =

P extends ${infer K}.${infer Rest}

? K extends keyof T

? PathValue<T[K], Rest>

: never

: P extends keyof T

? T[P]

: never;

// 配合一个工具函数

function getByPath<T, P extends string>(obj: T, path: P): PathValue<T, P> {

return (path.split('.').reduce((acc, key) => acc?.[key], obj as any)) as any;

}

const nickname = getByPath(response, 'data.user.info.nickname');

// nickname 的类型自动推导为 string,不用手动标注

这在处理后端接口字段时特别爽,不用为了访问一个深层字段专门定义一整个 interface。

场景二:一个函数既要返回数据又要返回错误信息,但不想用 throw

这个需求太常见了。传统做法是 throw 一个错误,但问题是有些业务场景里"没找到"不算异常,用 throw 反而让调用方很难受。Rust 的 Result 模式一直是我想借鉴的。

type Result<T, E = Error> =

| { ok: true; value: T }

| { ok: false; error: E };

async function fetchUser(id: string): Promise<Result<User>> {

const resp = await fetch(/api/users/${id});

if (!resp.ok) {

return { ok: false, error: new Error(HTTP ${resp.status}) };

}

return { ok: true, value: await resp.json() };

}

// 调用方可以这样写

const result = await fetchUser('123');

if (!result.ok) {

console.error('获取用户失败:', result.error.message);

return;

}

// 这里 TypeScript 知道 result.value 一定是 User 类型

console.log(result.value.name);

这比直接用 try-catch 包裹舒服多了,而且类型安全。调用方可以自由选择是处理错误还是直接抛出去。

场景三:动态表单字段——字段名和值类型要对应上

这个是我做低代码平台时踩的坑。需求是:有一个表单配置,每个字段有名字和校验规则,字段名是动态的,但值的类型要跟规则对上。

type FieldRule =

| { type: 'string'; min?: number; max?: number }

| { type: 'number'; min?: number; max?: number }

| { type: 'boolean' }

| { type: 'enum'; options: string[] };

type FormSchema = Record<string, FieldRule>;

type FormValues<S extends FormSchema> = {

[K in keyof S]: S[K]['type'] extends 'string' ? string

: S[K]['type'] extends 'number' ? number

: S[K]['type'] extends 'boolean' ? boolean

: S[K]['type'] extends 'enum' ? string

: never;

};

interface MyFormSchema {

username: { type: 'string'; min: 3; max: 20 };

age: { type: 'number'; min: 0; max: 150 };

subscribe: { type: 'boolean' };

}

// 这里 TypeScript 会自动推断出:

// { username: string; age: number; subscribe: boolean; }

type MyFormValues = FormValues<MyFormSchema>;

之前没这么写的时候,用 Record 一把梭,调接口时根本不知道字段类型对不对,一运行才发现校验失败,改起来贼痛苦。加上类型约束后,IDE 直接能提示你哪里类型不对。

场景四:组件的 Props 继承与扩展——Vue3 的 defineProps 也需要类型体操

用 Vue3 的 defineProps 时,经常遇到这种场景:有一个基础组件,现在要扩展它,加一些新的 props。常见的坑是把基础 props 和扩展 props 写两遍,然后有一堆重复字段。

// 基础弹窗的 props

interface BaseDialogProps {

visible: boolean;

title: string;

width?: string | number;

closable?: boolean;

}

// 确认弹窗在基础之上加了 content 和 onConfirm

interface ConfirmDialogProps extends BaseDialogProps {

content: string;

confirmText?: string;

cancelText?: string;

onConfirm: () => void;

onCancel?: () => void;

}

// 但问题是,扩展时我有时候想把某个基础字段覆盖掉(比如不用基础组件的 title)

// 这时候用 Omit 配合扩展更灵活:

type ConfirmDialogProps = Omit<BaseDialogProps, 'title'> & {

title?: string; // title 变成可选

content: string;

onConfirm: () => void;

};

不过说实话,这种场景多了之后,我更倾向直接用 TypeScript 的 class 继承,或者干脆把公共 props 抽成一个 useDialogProps composable。类型体操玩多了,代码可读性会下降——平衡点很重要。

写在最后

TypeScript 的类型系统本质上是一种**约束语言**,而不是"写起来更啰嗦的 JavaScript"。理解这一点很关键。类型写得好,维护成本会大幅下降;类型写得烂,维护成本反而比 JavaScript 还高。

我的经验是:先解决实际问题,在项目里遇到重复的类型定义需求时,再去抽象成工具类型。不要为了"类型体操"而"类型体操",那纯粹是炫技,真正的价值在于让类型帮你在编译期就把错误揪出来,而不是等到线上。

另外,配合 ESLint 的 @typescript-eslint/no-explicit-any 规则把 any 堵死,逼自己去找更好的类型表达方式,是一个很有效的方法。