返回

TypeScript 编译时执行函数?pluralize 类型难题与解法

javascript

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)。它能处理一些简单的情况。但很快你就会发现问题:

  1. 规则不全: 英语的复数规则相当复杂。比如 "day" 应该变成 "days",但按照上面的规则,它会错误地变成 "daies"。再比如 "child" -> "children", "mouse" -> "mice" 等不规则变化,这个简单的类型根本覆盖不了。
  2. 维护困难: 如果你想让这个类型更健壮,就得不断往里面添加更多的 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 文件

步骤:

  1. 安装依赖:

    npm install pluralize --save-dev
    # 或者
    yarn add pluralize -D
    
  2. 创建生成脚本 (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)

  3. 运行脚本:
    你可以手动运行 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 模式等
      }
    }
    
  4. 在代码中使用生成的类型:

    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 和运行时辅助对象的方法。它不能在 纯类型层面 生成复数,但可以让你在操作这些字符串时获得类型提示和关联。

  1. 定义常量列表:
    使用 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"
    
  2. 创建运行时映射:
    在运行时,使用 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
    
  3. 创建类型工具(改进版):
    我们可以做得更好一点。虽然不能 生成 复数字面量类型,但我们可以让 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 和运行时映射 ,但这通常只能让你在操作时关联类型,而不能在纯类型层面生成新的复数字面量类型。

手动映射类型只适用于非常简单和固定的场景,一般不推荐。