TypeScript 模板字面量类型的妙用:实现 SQL 查询语句的类型检查

TypeScript 的模板字面量类型(Template Literal Types)自 4.1 版本引入以来,一直是类型系统中被低估却极其强大的特性。它允许我们在类型层面拼接、推断和约束字符串,从而实现许多以往只能依赖运行时检查才能完成的工作。其中一个非常实用的场景,就是在编译期对 SQL 查询语句进行类型检查,避免字段名拼写错误、表名错误,甚至防止 SQL 注入式的字符串拼接问题。

本文将从模板字面量类型的基础讲起,逐步构建一个能对 SQL 查询语句进行类型约束的简易类型系统,并讨论其在实际项目中的价值与边界。

为什么需要类型检查 SQL?

在 Node.js 或前端项目中,我们经常需要写 SQL 语句。传统的写法是直接写字符串:

const sql = "SELECT id, name FROM users WHERE age > 18";

这样的代码没有任何类型保护。如果表名写错、字段名拼错,或者不小心把用户输入直接拼接到 SQL 中,编译器都不会报错。问题往往要到运行时才暴露,甚至引发安全漏洞。

当然,我们可以使用 ORM 或 Query Builder 来获得类型安全,但有时我们仍然需要手写 SQL,或者维护遗留代码。这时,模板字面量类型就能派上用场:它可以在不引入额外运行时库的情况下,为 SQL 字符串提供一定程度的静态检查。

模板字面量类型基础

模板字面量类型使用反引号语法,结合 ${} 占位符,在类型层面构造新的字符串字面量类型。例如:

type Greeting = `Hello, ${string}`;

const a: Greeting = "Hello, world"; // OK
const b: Greeting = "Hi, world";    // 错误

它还可以与联合类型结合,生成笛卡尔积:

type Color = "red" | "blue";
type Size = "small" | "large";
type Variant = `${Color}-${Size}`;
// "red-small" | "red-large" | "blue-small" | "blue-large"

此外,TypeScript 提供了几个内置的字符串操作类型:UppercaseLowercaseCapitalizeUncapitalize,它们可以配合模板字面量类型实现更复杂的转换。

构建 SQL 表结构类型

要对 SQL 进行类型检查,首先需要定义数据库的表结构。我们可以用接口来描述:

interface DatabaseSchema {
  users: {
    id: number;
    name: string;
    age: number;
    email: string;
  };
  orders: {
    id: number;
    userId: number;
    amount: number;
    createdAt: string;
  };
}

接下来,我们需要一个工具类型,用来判断某个字符串是否是某个表的合法字段名。这可以通过 keyof 和模板字面量类型结合实现。

解析 SELECT 语句

一个简单的 SELECT 语句格式为:

SELECT 字段列表 FROM 表名 [WHERE 条件]

我们希望在类型层面解析这个字符串,并检查字段列表中的每个字段是否属于指定的表。

首先,定义一个工具类型,用于将逗号分隔的字段字符串拆分成联合类型:

type Split<S extends string, Delimiter extends string> =
  S extends `${infer Head}${Delimiter}${infer Tail}`
    ? Head | Split<Tail, Delimiter>
    : S;

然后,去除字段名两边的空格:

type Trim<S extends string> =
  S extends ` ${infer R}` ? Trim<R> :
  S extends `${infer R} ` ? Trim<R> :
  S;

现在,我们可以定义一个类型,检查字段列表中的每个字段是否都是表 T 的键:

type ValidFields<
  T extends keyof DatabaseSchema,
  Fields extends string
> = Split<Fields, ","> extends infer F
  ? F extends string
    ? Trim<F> extends keyof DatabaseSchema[T]
      ? true
      : false
    : never
  : never;

但这个类型只能返回 true 或 false,我们更希望它直接约束整个 SQL 字符串。下面定义一个 SelectQuery 类型:

type SelectQuery<
  T extends keyof DatabaseSchema,
  Fields extends string
> = `SELECT ${Fields} FROM ${T}`;

然后,我们可以创建一个函数,接受这样的查询字符串,并利用条件类型进行校验:

function query<
  T extends keyof DatabaseSchema,
  Fields extends string
>(
  sql: SelectQuery<T, Fields> & (
    ValidFields<T, Fields> extends true ? unknown : never
  )
): void {
  // 执行查询
}

使用示例:

query("SELECT id, name FROM users"); // OK
query("SELECT id, wrongField FROM users"); // 错误:wrongField 不是 users 的字段
query("SELECT id, name FROM wrongTable"); // 错误:wrongTable 不是表名

这样,编译器就能在编写阶段捕获字段名和表名的错误。

处理 WHERE 子句

WHERE 子句的解析更复杂,但我们可以简化处理:只检查字段名是否合法,不检查操作符和值的类型。可以扩展 SelectQuery 类型,允许可选的 WHERE 部分:

type WhereClause<T extends keyof DatabaseSchema> =
  `WHERE ${string}`;

type SelectQueryWithWhere<
  T extends keyof DatabaseSchema,
  Fields extends string,
  Where extends string | undefined = undefined
> = Where extends undefined
  ? `SELECT ${Fields} FROM ${T}`
  : `SELECT ${Fields} FROM ${T} ${Where}`;

然后,在函数签名中,我们可以要求 WHERE 子句中的字段名也必须合法。这需要更复杂的解析,但核心思路是一样的:利用模板字面量类型提取字段名,再用 keyof 进行约束。

防止 SQL 注入

模板字面量类型还可以用来标记“已转义”的字符串。例如,我们可以定义一个 SafeString 类型,只有经过特定函数处理的字符串才能被标记为安全:

type SafeString = string & { __brand: "safe" };

function escape(value: string): SafeString {
  // 实际转义逻辑
  return value.replace(/'/g, "''") as SafeString;
}

function rawQuery(sql: SafeString): void {
  // 执行查询
}

const userInput = "'; DROP TABLE users; --";
rawQuery(userInput); // 错误:string 不能赋值给 SafeString
rawQuery(escape(userInput)); // OK

虽然这不是模板字面量类型的直接应用,但结合模板字面量类型,我们可以构建更复杂的 SQL 片段类型,确保只有合法的片段才能拼接。

实际应用中的限制

模板字面量类型虽然强大,但在 SQL 类型检查上仍有明显限制:

  1. 递归深度限制:TypeScript 对递归类型有深度限制,过长的字段列表可能导致编译器报错。
  2. 性能问题:复杂的模板字面量类型会显著增加编译时间,尤其是在大型项目中。
  3. 无法覆盖所有 SQL 语法:完整的 SQL 语法非常复杂,类型系统难以完全模拟。我们只能对常见模式进行约束。
  4. 错误信息不友好:当类型不匹配时,TypeScript 的错误信息可能非常冗长,难以定位问题。

因此,模板字面量类型更适合作为辅助手段,而不是完全替代 ORM 或 Query Builder。它可以在手写 SQL 的场景下提供一层额外的保护,减少低级错误。

总结

TypeScript 的模板字面量类型为字符串操作打开了新的大门。通过巧妙地组合条件类型、映射类型和字符串操作类型,我们可以在编译期对 SQL 查询语句进行一定程度的类型检查,包括表名、字段名的合法性验证。这不仅能提前发现错误,还能提升代码的可维护性。

当然,这种方案并非银弹,它需要权衡编译性能和类型复杂度。但在合适的场景下,它确实是一种优雅且实用的技术。希望本文能为你提供一些灵感,让你在下次手写 SQL 时,多一份类型安全的保障。

未经允许不得转载:任鹏个人博客 » TypeScript 模板字面量类型的妙用:实现 SQL 查询语句的类型检查

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏