TypeScript 作为 JavaScript 的超集,凭借强大的类型系统让代码更健壮、更易维护。在日常开发中,自定义工具类型往往能帮我们高效解决类型安全问题。本文就分享 5 个实用的 TypeScript 自定义工具类型,或许能帮你规避常见坑点,甚至启发你打造专属工具类型来应对业务难题。
1. 用Process优雅处理状态流转
开发中你是否写过这样的状态定义?- type State = {
- loading: boolean;
- error: string | null;
- data: User | null;
- };
- const state: State = {
- loading: true,
- error: "",
- data: null,
- };
复制代码 这种写法的问题很明显:后续判断状态时需要叠加多个条件,代码冗余且易出错,比如:- if (loading && !error && data) {} // 条件判断越来越复杂
复制代码 解决方案:用“区分联合类型”优化
通过status字段作为“区分标志”,明确状态的唯一可能性,避免多条件叠加:- type State =
- | { status: "idle" } // 初始空闲状态
- | { status: "busy" } // 加载中状态
- | { status: "ok"; data: User } // 成功状态(带数据)
- | { status: "fail"; error: string }; // 失败状态(带错误信息)
- const state: State = { status: "idle" };
复制代码 这样判断状态时只需检查status,类型系统会自动推断当前状态下可访问的字段(比如status: "ok"时才能访问data):- if (state.status === "ok") {
- console.log(state.data); // 此处访问data完全安全
- }
复制代码 进阶:自定义Process工具类型
重复定义上述状态结构会产生大量冗余代码。自定义Process工具类型,可复用异步流程的状态逻辑:- // 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 });
复制代码 使用示例- // 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优化前后的代码对比,能明显看到逻辑更简洁、状态更明确:- // 优化后
- 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统一 API 返回格式,明确区分“取消”“失败”“成功”三种状态:- type Result<TData> =
- | { status: "aborted" } // 请求被取消
- | { status: "fail"; error: unknown } // 请求失败(带错误信息)
- | { status: "ok"; data: TData }; // 请求成功(带返回数据)
复制代码 使用示例(API 请求)- // 调用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”的警告:- 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),易导致类型混淆。比如:- // 函数接收用户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 区分不同业务含义的原始类型:- // 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">;
复制代码 使用示例- // 函数参数改为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 & { age: number }),而非简洁的扁平结构。
解决方案:Prettify工具类型
通过Prettify将复杂类型“扁平化”,让编辑器显示更友好的类型定义:- type Prettify<TObject> = {
- [Key in keyof TObject]: TObject[Key]; // 遍历对象所有属性
- } & {}; // 强制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实现代码- // 递归拼接路径片段(无末尾斜杠)
- 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 字符串类型。
使用示例- // 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 代码更健壮、更易维护,也能更好地适应“类型驱动开发”的行业趋势。
扩展链接
SpreadJS——可嵌入您系统的在线Excel
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |