TypeScript 编译时执行函数?pluralize 类型难题与解法
2025-04-18 11:33:50
TypeScript 类型体操:编译期对字符串应用函数?难点与变通之道
咱们在写 TypeScript 的时候,有时候会冒出一些挺有意思的想法。比如,能不能写一个类型,它接收一个字符串,然后像调用 JavaScript 函数一样处理这个字符串,最后返回一个新的字符串类型?
最近就遇到了这样一个场景:我有一堆数据库表名的字符串,比如 "Student"
, "Class"
, "Day"
。在我的代码(特别是 ORM 定义关系时)里,经常需要用到它们的复数形式,像是 "Students"
, "Classes"
, "Days"
。我希望 TypeScript 能帮我自动做这个转换,这样在写类型定义的时候就能直接得到复数形式,增强类型安全。
直觉上,可能会想到用 pluralize
这个库,它专门干这活儿。
最初的尝试与遇到的问题
如果你刚接触 TypeScript 的高级类型,可能会像下面这样做,尝试用模板字面量类型 (Template Literal Types) 来模拟复数规则:
// 尝试用类型系统模拟 pluralize
type PluralizeAttempt1<T extends string> =
T extends `${infer S}sis` ? `${S}ses` // axis -> axes
: T extends `${infer S}y` ? `${S}ies` // story -> stories
: T extends `${infer S}s` ? T // bus -> bus (保持不变)
: `${T}s`; // book -> books
这段代码利用了 TypeScript 强大的类型推导能力(infer
)和条件类型(Conditional Types)。它能处理一些简单的情况。但很快你就会发现问题:
- 规则不全: 英语的复数规则相当复杂。比如 "day" 应该变成 "days",但按照上面的规则,它会错误地变成 "daies"。再比如 "child" -> "children", "mouse" -> "mice" 等不规则变化,这个简单的类型根本覆盖不了。
- 维护困难: 如果你想让这个类型更健壮,就得不断往里面添加更多的
extends
条件。这简直是在用类型系统重新实现一遍pluralize
库,费力不讨好,而且很容易出错。
既然手动模拟行不通,那能不能直接在类型里调用 pluralize
函数呢?就像这样:
import pluralize from "pluralize";
// 尝试在类型定义里直接调用 JS 函数 (这是错误的!)
// Type PluralizeAttempt2<T extends string> = T extends string ? pluralize(T) : never; // Error!
这段代码是行不通的。 TypeScript 编译器会给你报错。
问题在哪?编译时 vs. 运行时
要理解为什么不能直接在类型定义里调用 JavaScript 函数,关键在于区分 编译时 (Compile Time) 和 运行时 (Runtime) 。
- TypeScript 类型: 类型检查、类型推断、类型别名、接口等都发生在 编译时 。编译器(
tsc
)会根据你的类型注解和代码结构分析类型是否匹配、是否存在潜在错误。一旦编译成 JavaScript,所有的 TypeScript 类型信息默认都会被 擦除 (Type Erasure) ,不会出现在最终的 JS 代码里。类型系统主要是为了在开发阶段提供静态分析和保障。 - JavaScript 函数: 像
pluralize
这样的函数,以及你的业务逻辑代码,都是在 运行时 执行的。也就是说,它们是在编译后的 JavaScript 文件被 Node.js 或浏览器加载执行时才起作用。
type PluralizeAttempt2<T extends string> = ...
这个定义是在 编译时 处理的。TypeScript 编译器需要在这个阶段就确定 PluralizeAttempt2<"Student">
到底等于哪个具体的类型(比如 "Students"
)。但 pluralize("Student")
这个函数调用,必须等到代码 运行时 才能执行并得到结果 "Students"
。
编译器在分析类型时,没办法去“预执行”一个运行时的 JavaScript 函数。 它俩不在一个次元。所以,直接在类型定义里写 pluralize(T)
是行不通的。
解决方案:曲线救国
既然不能直接在编译时调用运行时函数,那我们该怎么办?思路得转变一下,不能强求在 类型定义本身 执行这个函数,而是要想办法在合适的时候(通常是编译前或编译中)生成我们需要的类型。
方案一:代码生成 (Code Generation) - 推荐
这是最实用也最准确的方法。思路很简单:我们写一个独立的脚本(比如 Node.js 脚本),在 编译之前 运行它。这个脚本读取你的原始字符串列表(比如数据库表名),使用 实际的 pluralize
库 (它在 Node.js 环境下可以正常运行)来计算出每个字符串的复数形式,然后 自动生成一个包含这些类型映射的 .ts
或 .d.ts
文件 。
步骤:
-
安装依赖:
npm install pluralize --save-dev # 或者 yarn add pluralize -D
-
创建生成脚本 (
scripts/generate-plural-types.ts
或.js
):// scripts/generate-plural-types.ts import fs from 'fs'; import path from 'path'; import pluralize from 'pluralize'; // 你的原始单数名词列表 (可以从文件、数据库等来源读取) const singularNouns = ['Student', 'Class', 'Day', 'Person', 'Axis', 'Query']; // 计算复数形式 const pluralMap: Record<string, string> = {}; singularNouns.forEach(noun => { pluralMap[noun] = pluralize(noun); }); // 生成 TypeScript 类型定义内容 let outputContent = `// This file is generated automatically. Do not edit manually.\n\n`; outputContent += `/**\n * Maps singular noun strings to their plural forms.\n */\n`; outputContent += `export type PluralMap = {\n`; for (const singular in pluralMap) { outputContent += ` "${singular}": "${pluralMap[singular]}";\n`; } outputContent += `};\n\n`; outputContent += `/**\n * A union type of all known singular nouns.\n */\n`; outputContent += `export type SingularNoun = keyof PluralMap;\n\n`; outputContent += `/**\n * A union type of all known plural nouns.\n */\n`; // 获取所有复数字符串值,并去重 const pluralValues = [...new Set(Object.values(pluralMap))]; outputContent += `export type PluralNoun = ${pluralValues.map(p => `"${p}"`).join(' | ')};\n\n`; outputContent += `/**\n * A utility type that gets the plural form of a given singular noun literal type.\n */\n`; outputContent += `export type Pluralize<T extends SingularNoun> = PluralMap[T];\n`; // 定义输出文件路径 const outputPath = path.resolve(__dirname, '../src/generated/plural-types.ts'); // 根据你的项目结构调整路径 // 确保目录存在 fs.mkdirSync(path.dirname(outputPath), { recursive: true }); // 写入文件 fs.writeFileSync(outputPath, outputContent); console.log(`✅ Plural types generated successfully at: ${outputPath}`);
(注意:运行这个 .ts 脚本可能需要
ts-node
或先编译成 .js) -
运行脚本:
你可以手动运行node scripts/generate-plural-types.js
(如果是编译后的js) 或ts-node scripts/generate-plural-types.ts
。更好的方式是把它集成到你的构建流程中。例如,在
package.json
里添加一个脚本:{ "scripts": { "generate-types": "ts-node ./scripts/generate-plural-types.ts", "build": "npm run generate-types && tsc" // 在编译前先生成类型 // 或者配合 watch 模式等 } }
-
在代码中使用生成的类型:
import { Pluralize, SingularNoun } from '../src/generated/plural-types'; type StudentPlural = Pluralize<"Student">; // 类型为 "Students" type DayPlural = Pluralize<"Day">; // 类型为 "Days" type PersonPlural = Pluralize<"Person">; // 类型为 "People" (pluralize 库能处理不规则变化) function getRelationName<T extends SingularNoun>(modelName: T): Pluralize<T> { // 这个函数现在有了精确的返回类型! const map: Record<SingularNoun, string> = { // 运行时仍然需要一个映射,但类型是安全的 Student: "Students", Class: "Classes", Day: "Days", Person: "People", Axis: "Axes", Query: "Queries" // ... 可以考虑也从生成的文件导入这个映射 }; return map[modelName] as Pluralize<T>; // 类型断言是安全的,因为类型和值都是生成的 } const studentsRelation = getRelationName("Student"); // studentsRelation 的类型是 "Students" const daysRelation = getRelationName("Day"); // daysRelation 的类型是 "Days" // const wrongRelation = getRelationName("Teacher"); // 编译错误! "Teacher" 不是有效的 SingularNoun
优点:
- 准确性高: 直接使用了成熟的
pluralize
库,结果准确,能处理各种复杂和不规则的情况。 - 类型安全: 生成的
Pluralize<T>
类型工具非常精确,能在编译时捕捉错误。 - 解耦: 类型生成逻辑和业务逻辑分开,代码更清晰。
- 自动化: 集成到构建流程后,每次构建都会自动更新类型。
缺点:
- 增加了一个构建步骤: 需要配置和运行代码生成脚本。
- 依赖外部脚本: 类型定义依赖于脚本的正确运行。
方案二:利用 as const
和运行时映射
如果你不想引入代码生成的步骤,或者你的列表是动态性不强且直接在代码中定义的,可以考虑这种结合了 as const
和运行时辅助对象的方法。它不能在 纯类型层面 生成复数,但可以让你在操作这些字符串时获得类型提示和关联。
-
定义常量列表:
使用as const
来告诉 TypeScript,这个数组及其内容是不可变的,并且应该推断出最具体的字面量类型。// 定义原始列表,并使用 as const const singularNouns = ['Student', 'Class', 'Day', 'Person', 'Axis'] as const; // 推断出单数字符串字面量的联合类型 type SingularNoun = typeof singularNouns[number]; // SingularNoun 类型为 "Student" | "Class" | "Day" | "Person" | "Axis"
-
创建运行时映射:
在运行时,使用pluralize
函数创建一个从单数到复数的映射对象。import pluralize from "pluralize"; // 创建一个运行时查找表 const pluralMap = Object.fromEntries( singularNouns.map(noun => [noun, pluralize(noun)]) ) as { [K in SingularNoun]: string }; // 提供基础类型提示 /* pluralMap 的值会是: { Student: "Students", Class: "Classes", Day: "Days", Person: "People", Axis: "Axes" } */ // 注意:这里我们没法直接得到 "Students" | "Classes" ... 这样的类型 // pluralMap 的类型是 { Student: string; Class: string; ... } // TypeScript 不知道 pluralize 的具体返回值是什么类型,只知道是 string
-
创建类型工具(改进版):
我们可以做得更好一点。虽然不能 生成 复数字面量类型,但我们可以让 TypeScript 知道pluralMap
的确切结构,从而在访问时获得更强的类型。import pluralize from "pluralize"; const singularNounsList = ['Student', 'Class', 'Day', 'Person', 'Axis'] as const; type SingularNoun = typeof singularNounsList[number]; // 创建一个精确类型的运行时映射 // (这一步稍微复杂,但可以一次性完成) function createPluralMap<const T extends ReadonlyArray<string>>(nouns: T): { [K in T[number]]: ReturnType<typeof pluralize> } { const map: Record<string, string> = {}; for (const noun of nouns) { map[noun] = pluralize(noun); } return map as { [K in T[number]]: ReturnType<typeof pluralize> }; } const pluralRuntimeMap = createPluralMap(singularNounsList); /* pluralRuntimeMap 的类型现在更精确了 (虽然返回值仍然是 string, 但 TypeScript 知道 key 和 value 的对应关系) 推断出的类型大致是: { readonly Student: string; readonly Class: string; readonly Day: string; readonly Person: string; readonly Axis: string; } */ // 类型工具,用于根据单数类型『查找』其在运行时映射中的对应值类型(仍然是 string) // 这个类型工具并没有凭空『计算』出复数,而是了查找操作的结果类型 type GetPluralFromMap<T extends SingularNoun> = typeof pluralRuntimeMap[T]; function getRelationNameRuntime<T extends SingularNoun>(modelName: T): GetPluralFromMap<T> { return pluralRuntimeMap[modelName]; // 现在这里类型是匹配的 } const studentsRel = getRelationNameRuntime("Student"); // studentsRel 类型是 string (虽然我们知道值是 "Students") const peopleRel = getRelationNameRuntime("Person"); // peopleRel 类型是 string (值是 "People") // 如果我们想要在类型系统中得到真正的字面量类型 "Students" 等, // 仍然需要方案一(代码生成)来显式定义这个映射。
优点:
- 无需额外构建步骤: 相对简单,不需要管理生成脚本。
as const
保留字面量类型: 便于后续基于这些字面量类型做其他操作。
缺点:
- 无法生成新的字面量类型:
GetPluralFromMap<"Student">
的结果类型通常只是string
,而不是"Students"
这个更精确的类型。除非pluralRuntimeMap
被定义得极其精确(比如手动指定每个返回值的类型,但这又回到了手动维护的问题)。 - 运行时依赖: 类型安全依赖于运行时
pluralRuntimeMap
对象与SingularNoun
类型定义的同步。如果列表变了,两者都需要更新。
方案三:手动类型映射 (不推荐,但值得一提)
回到最初的思路,如果你真的、真的只想在纯类型系统里做这件事,并且你的名词列表非常有限、变化很少、复数规则简单或固定,你可以硬编码一个映射类型:
type PluralMapManual = {
Student: "Students";
Class: "Classes";
Day: "Days";
Person: "People"; // 处理不规则情况
Axis: "Axes";
// ... 手动添加所有你需要转换的词
};
type SingularNounManual = keyof PluralMapManual;
type PluralizeManual<T extends SingularNounManual> = PluralMapManual[T];
type StudentPluralManual = PluralizeManual<"Student">; // "Students"
type PersonPluralManual = PluralizeManual<"Person">; // "People"
优点:
- 纯类型系统: 无需运行时函数,无需构建步骤。
- 类型精确: 可以得到精确的复数字面量类型。
缺点:
- 手动维护: 列表更新或增加新词时,需要手动修改
PluralMapManual
,非常繁琐且容易出错。 - 无法利用
pluralize
库: 你得自己处理所有复数规则,包括不规则变化。这正是我们一开始想避免的。
总结
直接在 TypeScript 类型定义中调用像 pluralize
这样的运行时 JavaScript 函数是不可行的,因为类型检查发生在编译时,而函数执行在运行时。
最推荐的解决方案是 代码生成 :编写一个脚本,在编译前使用 pluralize
库生成一个包含单数到复数映射的 TypeScript 类型文件。这样既能利用 pluralize
的强大功能,又能获得完全精确和类型安全的结果。
如果不想引入构建步骤,可以利用 as const
和运行时映射 ,但这通常只能让你在操作时关联类型,而不能在纯类型层面生成新的复数字面量类型。
手动映射类型只适用于非常简单和固定的场景,一般不推荐。