TypeScript で実行時の入力を含む文字列を型で弾く

TypeScript (4.7 時点) において, 文字列に付けられる型には以下の 3 つ (とそのユニオン型) があります.

  • 文字列型 string
  • 文字列リテラル型 ("foo" など)
  • テンプレートリテラル型 (`data-${string}` など)

これらのうち, 実行時の入力, 特に事前にパターンが想定されていないような任意の入力が含まれるような文字列に対しては, stringstring を含むテンプレートリテラル型を付けることはできても, 文字列リテラル型を付けることはできません. 文字列リテラル型を付けるためには型検査時 (実行の前) に入力文字列の内容がわかっている必要があるので, まあそれはそうですね.

このことを利用して, 実行時の入力を含む文字列を与えようとすると型検査に失敗するような関数を作ることができそうです.

まずは与えられた型が文字列リテラル型, または文字列リテラル型のユニオン型かどうかを判定する述語が定義できます. できるんです.

// 型が付けられる値の数が有限かという意味で finite
type IsFiniteString<T extends string> =
    string extends T ? false
  : T extends "" ? true
  : T extends `${infer H}${infer R}` ? (
      string extends H ? false
    : `${number}` extends H ? false
    : `${bigint}` extends H ? false
    : IsFiniteString<R>
  )
  : never;

注意点として, `margin-${"top" | "bttom" | "left" | "right"}` といった文字列リテラル型のユニオン型を含むようなテンプレートリテラル型については, 展開後の "margin-top" | "margin-bottom" | "margin-left" | "margin-right" のような文字列リテラル型のユニオン型と同等のものとして扱われます. またテンプレートリテラル型には string, number, bigint の他に boolean, undefined, null といった型も含められますが, これらの取り得る値は高々有限なので, やはり展開できて通常の文字列リテラル型のユニオン型と同等に扱われます.

この述語に対するテストは以下の通り.

type AssertTrue<T extends true> = T;
type AssertFalse<T extends false> = T;

type T0 = AssertTrue<IsFiniteString<"">>;
type T1 = AssertTrue<IsFiniteString<"top" | "bottom" | "left" | "right">>;
type T2 = AssertTrue<IsFiniteString<`margin-${"top" | "bttom" | "left" | "right"}`>>;
type T3 = AssertTrue<IsFiniteString<`x = ${boolean}`>>;

type F0 = AssertFalse<IsFiniteString<string>>;
type F1 = AssertFalse<IsFiniteString<`data-${string}`>>;
type F2 = AssertFalse<IsFiniteString<`${number}px`>>;
type F4 = AssertFalse<IsFiniteString<`user:${bigint}`>>;

この IsFinite<T> を使って, IsFinite<T> を通過した型 T しか引数に受け取らない関数を定義することができます.

type FiniteString<T extends string> = IsFiniteString<T> extends true ? T : never;

// なんらかのクエリを実行する関数
declare function execQuery<T extends string>(
  query: FiniteString<T>,
  params?: readonly unknown[]
): Promise<unknown>;

この関数がどう便利かというと, 実行時に組み立てた文字列を与えようとすると型エラーが発生するので, つまり SQL インジェクションが発生するような実装を未然に防ぐことができます (Playground). よかったですね.

// ユーザー入力の文字列
declare const userId: string;

// Error
execQuery(`SELECT * FROM users WHERE id = '${userId}'`);
//       ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Argument of type 'string' is not assignable to parameter of type 'never'.

// Error
execQuery(`SELECT * FROM users WHERE id = '${userId}'` as const);
//        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Argument of type '`SELECT * FROM users WHERE id = ${string}`' is not assignable to parameter of type 'never'.

// OK
execQuery(`SELECT * FROM users WHERE id = ?`, [userId]);

文脈

こちらの Go での実装と同じようなことを TypeScript でやるとどうなるかという話でした.

以前にも TypeScript で実行時に文字列を組み立てていると型エラーになるような関数を作っていたので, 似たような話だなと思って今回の記事を書いています.

以前の記事では TypeScript の進化によって一般には使えなくなったという話をしていましたが, 今回の例のように実行時の任意の入力を使って文字列を組み立てている場合については, 上で説明した通り引き続き同様の方法で型エラーを引き起こすことができそうです.

ここで紹介した execQuery のような, 特定の述語を通過した型しか受け付けない関数の定義方法の詳細については以下の記事をどうぞ.