TypeScript上級講座 #4 Template literal types

#3 Conditional typesとinferで分岐と抽出を学びました。今回扱う道具は — 型レベルで文字列を合成するtemplate literal typesです。JavaScriptの`${...}`と形が同じですが、型のレベルで動作します。

基本 — 文字列リテラルを合成する #

template literal type基本
type Greeting = `hello, ${string}`;

const a: Greeting = 'hello, curtis';   // OK
const b: Greeting = 'hi, curtis';       // ✗ 'hello, 'で始まらなければならない
const c: Greeting = 'hello, ';          // OK (後ろが空文字列でもstring)

${string}の位置にはどんな文字列でも入れられます。形はhello, で始まる必要があります。こうしておくとコンパイル時にパターンを強制できます。

プレースホルダにはstringの他にnumberboolean、またはunionリテラルを入れることができます。

位置に何が入るか
type ID = `user-${number}`;
const a: ID = 'user-42';      // OK
const b: ID = 'user-abc';     // ✗ numberの位置

type Color = 'red' | 'blue' | 'green';
type ColorClass = `color-${Color}`;
// 'color-red' | 'color-blue' | 'color-green'

Colorのようなunionを入れると結果のunionに分配されます。#3の分配条件付きと同じ原理です。

unionが二つ入ると — 直積 #

位置が二つあって両方unionなら、直積(カーテシアン積)が作られます。

直積で増える
type Side = 'top' | 'right' | 'bottom' | 'left';
type Size = 'sm' | 'md' | 'lg';

type Spacing = `m-${Side}-${Size}`;
// 'm-top-sm' | 'm-top-md' | ... | 'm-left-lg'
// 全部で12個

CSSクラス名のようなケースで価値が大きいです。m-top-smのようなものが12個も作られ、新しいsizeを追加すれば自動で4個ずつ増えます。

組み込み文字列ヘルパー #

template literal typeの隣には四つの組み込みヘルパーがあります。

ヘルパー結果
Uppercase<S>‘hello’ → ‘HELLO’
Lowercase<S>‘HELLO’ → ‘hello’
Capitalize<S>‘hello’ → ‘Hello’
Uncapitalize<S>‘Hello’ → ‘hello’
組み込みヘルパー
type A = Uppercase<'hello'>;     // 'HELLO'
type B = Capitalize<'curtis'>;   // 'Curtis'
type C = Uncapitalize<'Curtis'>; // 'curtis'

type EventNames = Uppercase<'click' | 'change'>;
// 'CLICK' | 'CHANGE'

型レベルで文字列を加工するというのが奇妙に見えるかもしれませんが、ライブラリの型を書くとき非常によく登場します。

実戦1 — setterメソッド自動生成 #

#2でちらっと見たパターン。オブジェクトの各キーごとにsetterを自動的に作る。

setterメソッド自動生成
type Setters<T> = {
  [K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void;
};

type UserSetters = Setters<{ name: string; age: number; email: string }>;
// {
//   setName: (value: string) => void;
//   setAge: (value: number) => void;
//   setEmail: (value: string) => void;
// }

三つの道具が組み合わさっています。

  1. mapped type — キー一つ一つについて
  2. template literalset + 大文字化されたキー
  3. Capitalize<string & K> — Kがstringのときだけ最初の文字を大文字に

string & Kの意味は「Kがsymbol/numberキーかもしれないので、その中でstringのものだけ」です。mapped typeを使うときによく登場する慣用句です。

実戦2 — イベントハンドラ名のマッピング #

DOMイベントやカスタムイベントの名前からハンドラ名を自動生成。

event → handler名
type EventName = 'click' | 'change' | 'focus' | 'blur';
type HandlerName = `on${Capitalize<EventName>}`;
// 'onClick' | 'onChange' | 'onFocus' | 'onBlur'

type Handlers = {
  [K in HandlerName]: (e: Event) => void;
};
// {
//   onClick: (e: Event) => void;
//   onChange: (e: Event) => void;
//   ...
// }

Reactが使うイベントハンドラ名の規約が正確にこのパターンです。ライブラリの内側で似た自動生成によく出会います。

実戦3 — ルートパラメータの抽出 #

ルートパターン文字列からパラメータ名を引き出す — TypeScriptの最も有名なトリックの一つです。

ルートからパラメータ抽出
type ExtractParams<S extends string> =
  S extends `${string}:${infer P}/${infer Rest}`
    ? P | ExtractParams<`/${Rest}`>
    : S extends `${string}:${infer P}`
      ? P
      : never;

type A = ExtractParams<'/users/:id'>;          // 'id'
type B = ExtractParams<'/users/:userId/posts/:postId'>;
// 'userId' | 'postId'

template literal + infer + 再帰がすべて入ったコードです。最初に見ると怖そうに見えますが、一行ずつ読めば難しくありません。

  1. ${string}:${infer P}/${infer Rest}:名前/...パターンなら名前と残りを捉える
  2. P | ExtractParams<...> — 捉えた名前と残りからまた抽出
  3. :名前だけがあって後ろがなければ、それが最後のパラメータ

このパターンが入ると、次のような安全なルーター関数が可能になります。

安全なnavigate
function navigate<S extends string>(
  pattern: S,
  params: Record<ExtractParams<S>, string>
): void {
  // ...
}

navigate('/users/:id', { id: '42' });          // OK
navigate('/users/:id', { name: 'カーティス' });     // ✗ id欠落
navigate('/users/:id/posts/:postId', {
  id: '1',
  postId: '7',
});                                              // OK

ルートパターンだけ見て、どんなパラメータが必要かをコンパイラが正確に推論します。Next.jsやReact Routerの型定義の内側に似たパターンが入っています。

実戦4 — CSS単位の強制 #

CSSの値を受け取るpropが単純なstringだと、ユーザーが「20」のような単位の抜けた文字列を送る可能性があります。template literalで単位を強制できます。

単位強制
type CSSUnit = 'px' | 'rem' | 'em' | '%';
type CSSValue = `${number}${CSSUnit}`;

const a: CSSValue = '20px';     // OK
const b: CSSValue = '1.5rem';   // OK
const c: CSSValue = '20';       // ✗ 単位なし
const d: CSSValue = 'auto';     // ✗ 'auto'を許可するならunion追加

無駄に厳しすぎることもありますが、デザインシステムコンポーネントのpropとして使えばミスが減ります。'auto' | 'inherit'のようなキーワードが必要ならunionに追加します。

落とし穴 — stringは広すぎる #

template literalに${string}を入れるとすべてのstringを受け取るところができてしまうので、結果の型も結局どんな文字列でも通ってしまいます。

広すぎるケース
type ApiPath = `/api/${string}`;

const a: ApiPath = '/api/users';   // OK
const b: ApiPath = '/api/';        // OK
const c: ApiPath = '/api/foo bar'; // OK — 空白も通る

絞りたいなら${string}の代わりにより狭いunionを使うか、別途の検証ロジックを加える必要があります。TypeScriptだけですべての形式を強制するのは難しいということを意識しておいてください。

落とし穴 — 深すぎる再帰は止まる #

ルートパラメータ抽出のような再帰パターンは強力ですが、TypeScriptの再帰深さの限界(約50回)があり、長すぎる入力では止まります。

再帰の限界
// 50個以上の:paramがあるルートは処理できない
type Many = ExtractParams<'/a/:p1/:p2/.../:p100'>;
// コンパイラが諦める (Type instantiation is excessively deep)

実務ではほぼぶつかることはありませんが、ライブラリの型を書くときに時折出会います。安全のために再帰深さを意識して書くなら、末尾再帰形式の方がより耐えます (捉えた結果を仮引数で累積していく形式)。

まとめ #

今回整理した内容:

  • template literal type — `${...}`で文字列合成
  • 位置にstring/number/literal union可能
  • unionが入ると直積で分配
  • 組み込みヘルパー — Uppercase / Lowercase / Capitalize / Uncapitalize
  • mapped + template literalでキー名自動生成 (setterパターン)
  • infer + 再帰 + template literalでルートパラメータ抽出のようなトリック
  • ${string}は広すぎる — 絞り込みは別途の検証が必要
  • 再帰深さの限界を意識

次の記事(#5 Discriminated unionと型ガード深掘り)では分岐可能な型のモデリング — discriminated unionを深く扱い、ユーザー定義型ガードとassertion関数、そしてbranded typeまで整理します。

X