TypeScript 作为 JavaScript 的超集,凭借强大的类型系统让代码更健壮、更易维护。在日常开发中,自定义工具类型往往能帮我们高效解决类型安全问题。本文就分享 5 个实用的 TypeScript 自定义工具类型,或许能帮你规避常见坑点,甚至启发你打造专属工具类型来应对业务难题。
1. 用Process优雅处理状态流转
开发中你是否写过这样的状态定义?
TypeScript
type State = {
loading: boolean;
error: string | null;
data: User | null;
};
const state: State = {
loading: true,
error: "",
data: null,
};
这种写法的问题很明显:后续判断状态时需要叠加多个条件,代码冗余且易出错,比如:
TypeScript
if (loading && !error && data) {} // 条件判断越来越复杂
解决方案:用"区分联合类型"优化
通过status字段作为"区分标志",明确状态的唯一可能性,避免多条件叠加:
TypeScript
type State =
| { status: "idle" } // 初始空闲状态
| { status: "busy" } // 加载中状态
| { status: "ok"; data: User } // 成功状态(带数据)
| { status: "fail"; error: string }; // 失败状态(带错误信息)
const state: State = { status: "idle" };
这样判断状态时只需检查status,类型系统会自动推断当前状态下可访问的字段(比如status: "ok"时才能访问data):
TypeScript
if (state.status === "ok") {
console.log(state.data); // 此处访问data完全安全
}
进阶:自定义Process工具类型
重复定义上述状态结构会产生大量冗余代码。自定义Process<TData, TError>工具类型,可复用异步流程的状态逻辑:
TypeScript
// utility-types.ts
/**
* 描述异步流程的状态
* 默认TData为void(无数据),TError为Error(默认错误类型)
*/
type Process<TData = void, TError = Error, TSkipIdle = false> =
// 可选移除idle状态(TSkipIdle为true时不包含idle)
| (TSkipIdle extends false ? { status: "idle" } : never)
| { status: "busy" } // 加载中状态
// TData为void时,不包含data字段
| (TData extends void ? { status: "ok" } : { status: "ok"; data: TData })
// TError为void时,不包含error字段
| (TError extends void ? { status: "fail" } : { status: "fail"; error: TError });
使用示例
TypeScript
// 1. 包含idle状态,成功时返回User类型数据
const state: Process<User> = { status: "idle" };
// 2. 成功状态(带User数据)
const successState: Process<User> = { status: "ok", data: { id: "1", name: "测试用户" } };
// 3. 失败状态(默认Error类型)
const errorState: Process<User> = { status: "fail", error: new Error("请求失败") };
// 4. 移除idle状态(比如组件挂载即加载,无需初始空闲态)
const stateWithoutIdle: Process<Comment, Error, true> = { status: "busy" };
实际业务场景示例(异步请求用户数据)
用Process优化前后的代码对比,能明显看到逻辑更简洁、状态更明确:
TypeScript
// 优化后
type State = Process<User>;
let state: State = { status: "idle" };
const getUser = async (userId: string) => {
state = { status: "busy" }; // 开始加载
try {
const user = await apiCallToUser(userId);
state = { status: "ok", data: user }; // 成功:更新数据
} catch (error) {
state = { status: "fail", error: "请求出错" }; // 失败:更新错误信息
}
};
// 优化前(冗余且易出错)
type State = {
loading: boolean;
error: string | null;
data: User | null;
};
let state: State = { loading: true, error: "", data: null };
const getUser = async (userId: string) => {
state = { loading: true, error: null, data: null }; // 需手动重置所有字段
try {
const user = await apiCallToUser(userId);
state = { loading: false, error: null, data: user }; // 手动关闭loading
} catch (error) {
state = { loading: false, error: "出错了", data: null }; // 易遗漏字段更新
}
};
优化后优势:
- 状态组合从 8 种(loading×error×data)减少到 4 种,逻辑更清晰;
- 避免"忘记关闭 loading"等低级错误,类型系统自动校验状态合法性。
2. 用Result统一处理 API 交互
使用fetch或axios时,若忘记写.catch()会导致未处理的 Promise 错误;部分 API 还会返回非标准的错误数据格式,增加处理复杂度。
解决方案:Result工具类型
用Result<TData>统一 API 返回格式,明确区分"取消""失败""成功"三种状态:
TypeScript
type Result<TData> =
| { status: "aborted" } // 请求被取消
| { status: "fail"; error: unknown } // 请求失败(带错误信息)
| { status: "ok"; data: TData }; // 请求成功(带返回数据)
使用示例(API 请求)
TypeScript
// 调用API,返回Result<User>类型
const result = await service<User>("https://api.example.com/user");
// 处理不同状态
if (result.status === "aborted") {
return; // 取消请求,不做后续处理
}
if (result.status === "fail") {
alert(`请求失败:${result.error}`); // 处理错误
return;
}
if (result.status === "ok") {
alert(`请求成功:${result.data.name}`); // 处理成功数据
return;
}
// exhaustive check(完整性校验):确保所有状态都被处理
// 若遗漏某个状态,TypeScript会编译报错
const _exhaustiveCheck: never = result;
React 场景的额外优势
在 React 的useEffect中使用时,可轻松处理"请求取消"场景(比如组件卸载前取消请求),避免"已卸载组件触发 setState"的警告:
TypeScript
useEffect(() => {
const controller = new AbortController();
const fetchUser = async () => {
const result = await service<User>("https://api.example.com/user", {
signal: controller.signal,
});
if (result.status === "aborted") return; // 组件卸载后,请求被取消,不更新状态
if (result.status === "ok") setUser(result.data);
};
fetchUser();
// 组件卸载时取消请求
return () => controller.abort();
}, []);
3. 用Brand避免"原始类型滥用"
"原始类型滥用"是常见代码坏味道:用string/number等原始类型表示业务概念(如用户 ID、文档 ID),易导致类型混淆。比如:
TypeScript
// 函数接收用户ID(string类型)
const getUserPosts = (userId: string) => {
// 根据用户ID查询文章
};
const documentId = "doc-123"; // 文档ID(也是string类型)
getUserPosts(documentId); // TypeScript无报错,但逻辑上是错误的!
TypeScript 无法区分"用户 ID"和"文档 ID"(两者都是string),导致潜在 bug。
解决方案:自定义Brand工具类型
通过"品牌化类型"(Branded Type)为原始类型添加"唯一标识",让 TypeScript 区分不同业务含义的原始类型:
TypeScript
// Brand工具类型:TData为原始类型,TLabel为业务标识
type Brand<TData, TLabel extends string> = TData & { __brand: TLabel };
// 定义"用户ID"类型(string + 品牌标识'UserId')
type UserId = Brand<string, "UserId">;
// 定义"文档ID"类型(string + 品牌标识'DocumentId')
type DocumentId = Brand<string, "DocumentId">;
使用示例
TypeScript
// 函数参数改为UserId类型
const getUserPosts = (userId: UserId) => {
// ...
};
const documentId = "doc-123"; // DocumentId类型(或普通string)
getUserPosts(documentId); // TypeScript编译报错!
// 错误信息:Argument of type 'string' is not assignable to parameter of type 'UserId'
// 正确用法:通过类型断言创建UserId(建议在验证函数中使用)
const validUserId = "user-456" as UserId;
getUserPosts(validUserId); // 无报错
关键特性
- 无运行时开销:__brand字段仅在编译时存在,编译后会被移除,不影响代码性能;
- 强制类型校验:必须显式创建"品牌化类型",避免无意识的类型混淆。
4. 用Prettify优化类型显示
使用Pick/Omit或交叉类型(&)创建复杂类型时,编辑器 hover 显示的类型定义往往冗长混乱(比如显示Pick<User, "id" | "name"> & { age: number }),而非简洁的扁平结构。
解决方案:Prettify工具类型
通过Prettify将复杂类型"扁平化",让编辑器显示更友好的类型定义:
TypeScript
type Prettify<TObject> = {
[Key in keyof TObject]: TObject[Key]; // 遍历对象所有属性
} & {}; // 强制TypeScript展开类型结构
使用示例
TypeScript
// 复杂类型(Pick + 交叉类型)
type UserBase = Pick<User, "id" | "name">;
type UserWithAge = UserBase & { age: number };
// 未使用Prettify:hover显示 "Pick<User, 'id' | 'name'> & { age: number }"
type UglyUser = UserWithAge;
// 使用Prettify:hover显示 "{ id: string; name: string; age: number }"
type PrettyUser = Prettify<UserWithAge>;
使用建议
无需全局滥用,仅在"类型结构复杂、hover 显示混乱"时使用(比如公共组件的 Props 类型、复杂业务模型)。
5. 用StrictURL实现类型安全的路由
手动拼接 URL(如'/users/' + userId)易出错(比如少写斜杠、参数格式错误)。StrictURL工具类型可在编译时强制校验 URL 结构,避免运行时错误。
StrictURL实现代码
TypeScript
// 递归拼接路径片段(无末尾斜杠)
type ToPath<TItems extends string[]> = TItems extends [
infer Head extends string,
...infer Tail extends string[]
]
? `${Head}${Tail extends [] ? "" : `/${ToPath<Tail>}`}`
: "";
// 递归构建查询参数字符串
type ToQueryString<TParams extends string[]> = TParams extends [
infer Head extends string,
...infer Tail extends string[]
]
? `${Head}=${string}${Tail extends [] ? "" : `&${ToQueryString<Tail>}`}`
: "";
// 主工具类型:构建完整URL类型
type StrictURL<
TProtocol extends "https" | "http", // 协议(仅支持http/https)
TDomain extends `${string}.${"com" | "dev" | "io"}`, // 域名(仅支持.com/.dev/.io)
TPath extends string[] = [], // 路径片段(可选)
TParams extends string[] = [] // 查询参数(可选)
> = `${TProtocol}://${TDomain}${TPath extends [] ? "" : `/${ToPath<TPath>}`}${TParams extends [] ? "" : `?${ToQueryString<TParams>}`}`;
核心原理拆解
- infer关键字:类型层面的"解构赋值",从数组类型中提取"首元素"(Head)和"剩余元素"(Tail);
- 递归条件类型:通过递归处理数组的每个元素,直到数组为空(基准条件),最终拼接成完整的 URL 字符串类型。
使用示例
TypeScript
// 1. 带动态路径的路由(articles/[id]/detail)
type ArticleDetailRoute = StrictURL<"https", "example.com", ["articles", string, "detail"]>;
// hover显示:"https://example.com/articles/${string}/detail"
// 2. 带查询参数的路由(search?q=&source=)
type SearchRoute = StrictURL<"https", "google.com", ["search"], ["q", "source"]>;
// hover显示:"https://google.com/search?q=${string}&source=${string}"
// 3. 错误示例:域名后缀不合法(TypeScript编译报错)
type InvalidDomainRoute = StrictURL<"https", "example.cn", ["users"]>;
// 错误信息:Type '"example.cn"' does not satisfy the constraint `${string}.${"com" | "dev" | "io"}`
行业应用
现代框架(如 Next.js 13+的 App Router)已采用类似思路实现"类型安全路由",从编译阶段规避"URL 路径错误""参数缺失"等问题。
总结
本文介绍的 5 个工具类型,覆盖了"状态管理""API 交互""类型安全""类型显示优化""路由校验"等高频场景。它们的核心价值在于:
- 减少重复代码,提升开发效率;
- 利用 TypeScript 类型系统,提前规避运行时 bug;
- 让代码结构更清晰,降低团队协作成本。
TypeScript 的工具类型生态远不止于此,建议根据实际业务需求灵活扩展(比如为表单校验、权限控制创建专属工具类型)。AI 工具(如 ChatGPT、Cursor)可辅助快速生成复杂工具类型,但理解其底层原理(如递归、infer、交叉类型)仍是关键。
掌握这些工具类型,能让你的 TypeScript 代码更健壮、更易维护,也能更好地适应"类型驱动开发"的行业趋势。