TypeScriptのTemplate Literal Typesを例で学ぶ

1. 概要

Template Literal Typesを使うことで、TypeScriptの型システムの中でバッククォート構文をそのまま使用できます。これにより、コンパイル時に文字列型を連結、変換、パースできます。本記事では、具体的な例を通じて各機能を説明します。unionの連結、mapped typeでのキー名変更、ルートからのパラメータ抽出、型安全なイベントエミッターの実装などです。読み終わったら、いつこの技術を使うべきかが分かるでしょう。また、型テストの方法と、コンパイラを遅くしたり理解しにくいエラーを出す落とし穴についても知ることができます。

この記事の例はすべてTypeScript 5.x、strict: trueの設定で動作します。

2. 基本的な構文

Template literal typeは、実行時のtemplate stringと全く同じように見えます。唯一の違いは、${...}内の式が値ではなく型を含むことです:

type OrderId = `ORD-${number}`;

const ok: OrderId = "ORD-1024";
// const bad: OrderId = "INV-1024";
// Error: Type '"INV-1024"' is not assignable to type '`ORD-${number}`'.

OrderId型は、ORD-で始まり、その後に数値を表す文字列が続くすべての文字列を受け入れます。string, number, bigint, boolean, null, undefinedおよびそれらのliteral typeを内挿することができます。

よく知られた例は、CSSの単位付きの値です:

type Pixel = `${number}px`;

function setWidth(el: HTMLElement, width: Pixel): void {
  el.style.width = width;
}

setWidth(document.body, "320px"); // OK
// setWidth(document.body, "320");  // Error: "px"が不足

コンパイラが単位を忘れるエラーを捉えるようになりました。以前はこのエラーはブラウザを開くときだけ見つかりました。

3. unionとの組み合わせ:デカルト積

内挿部分がunionの場合、TypeScriptはそれぞれのメンバーにtemplateを分配します。複数のunionがある場合、結果はすべての可能な組み合わせになります:

type Size = "sm" | "md" | "lg";
type Variant = "primary" | "danger";

type ButtonClass = `btn-${Size}-${Variant}`;

エディタでButtonClassにマウスを置くと、以下が見えます:

type ButtonClass = "btn-sm-primary" | "btn-sm-danger" | "btn-md-primary"
                 | "btn-md-danger" | "btn-lg-primary" | "btn-lg-danger"

ここで3 × 2 = 6個のメンバーが自動的に生成されました。Sizeに"xl"を追加すると、ButtonClassも自動的に更新されます。他の場所を編集する必要はありません。

注意: 組み合わせの数は指数関数的に増加します。結果のunionが100,000以上のメンバーを超える場合、TypeScriptは*“Expression produces a union type that is too complex to represent”*というエラーを報告します。複数の大きなunionを連結しないでください。例えば、1,000個の製品コードのunionを200個の国コードのunionと連結するなど。

4. 組み込みの文字列変換型

TypeScriptは大文字/小文字を変換するための4つのintrinsic typeを提供しています:

type Event = "orderCreated";

type A = Uppercase<Event>;     // "ORDERCREATED"
type B = Lowercase<Event>;     // "ordercreated"
type C = Capitalize<Event>;    // "OrderCreated"
type D = Uncapitalize<"OrderCreated">; // "orderCreated"

これらはコンパイラに組み込まれており、TypeScriptで書かれていません。この4つの型はtemplate literalと一緒に使われることが多く、命名規則に従った名前を作成します。例えばon${Capitalize<K>}や${Uppercase<K>}_ENVなど。

type ConfigKey = "databaseUrl" | "apiToken";
type EnvName = `APP_${Uppercase<ConfigKey>}`;
// "APP_DATABASEURL" | "APP_APITOKEN"

5. Mapped typeでのキー名変更

これはtemplate literal typesが最も活躍する場所です。mapped type内のas句を使えば、新しい型を作成するときに各キーを名前変更できます。

5.1. Interfaceからgetterを生成する

interface Customer {
  name: string;
  email: string;
  loyaltyPoints: number;
}

type Getters<T> = {
  [K in keyof T & string as `get${Capitalize<K>}`]: () => T[K];
};

type CustomerGetters = Getters<Customer>;

結果:

type CustomerGetters = {
  getName: () => string;
  getEmail: () => string;
  getLoyaltyPoints: () => number;
}

各部分を説明します:

  • keyof T & stringはnumberとsymbol型のキーをフィルタリングします。理由はCapitalizeがstringのみを受け入れるからです。& stringを省略すると、*“Type ‘K’ does not satisfy the constraint ‘string’”*というエラーが出ます。
  • as \get${Capitalize}`はnameをgetName`に名前変更します。
  • () => T[K]は元のpropertyの戻り値の型を保持します。

このような型を実装するクラスは、getterが不足していたり戻り値の型が間違っていたりするとコンパイラがすぐにエラーを出します:

class CustomerView implements CustomerGetters {
  constructor(private readonly data: Customer) {}
  getName = () => this.data.name;
  getEmail = () => this.data.email;
  getLoyaltyPoints = () => this.data.loyaltyPoints;
}

5.2. neverを返すことでキーをフィルタリング

as内の式がneverを返す場合、そのキーは結果から除外されます。この方法を組み合わせることで、プレフィックスに基づいてキーを選択できます:

interface PaymentService {
  chargeCard(amount: number): Promise<void>;
  chargeWallet(amount: number): Promise<void>;
  refund(paymentId: string): Promise<void>;
}

type ChargeMethods<T> = {
  [K in keyof T as K extends `charge${string}` ? K : never]: T[K];
};

type OnlyCharge = ChargeMethods<PaymentService>;
// { chargeCard: ...; chargeWallet: ... }

refundはcharge${string}パターンに一致しないため、除外されます。

6. inferを使った文字列パース

Template literalは文字列の連結だけでなく、抽出にも使えます。conditional typeと組み合わせてinferを使用します。

6.1. 簡単な部分の抽出

type StripPrefix<S extends string> =
  S extends `ORD-${infer Rest}` ? Rest : never;

type X = StripPrefix<"ORD-1024">; // "1024"
type Y = StripPrefix<"INV-1024">; // never

パターンがマッチするとき、TypeScriptは残りの部分をRestに割り当てます。

6.2. ルートからパラメータを抽出

これが最も一般的な実用的な例です。"/users/:userId/orders/:orderId"という文字列から、"userId" | "orderId"という型を得たいとします:

type ExtractParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<`/${Rest}`>
    : Path extends `${string}:${infer Param}`
      ? Param
      : never;

type P = ExtractParams<"/users/:userId/orders/:orderId">;
// "userId" | "orderId"

これの動作方法:

  1. 最初のブランチが"/users/" + ":" + "userId" + "/" + "orders/:orderId"にマッチします。各プレースホルダーが直後のリテラル文字に最初にマッチします。したがってParamは"userId"です。
  2. "/orders/:orderId"で再帰します。今度はパラメータの後に/がないので、最初のブランチは失敗します。
  3. 2番目のブランチがマッチして"orderId"を返します。
  4. 文字列に:がない場合、結果はneverです。

URLビルダー関数で使ってみましょう:

type RouteParams<Path extends string> = {
  [K in ExtractParams<Path>]: string;
};

function buildUrl<Path extends string>(
  path: Path,
  params: RouteParams<Path>,
): string {
  const values = params as Record<string, string>;
  return path.replace(/:(\w+)/g, (_, key: string) =>
    encodeURIComponent(values[key]),
  );
}

const url = buildUrl("/users/:userId/orders/:orderId", {
  userId: "42",
  orderId: "ORD-7",
});
console.log(url);

// buildUrl("/users/:userId", { id: "42" });
// Error: Object literal may only specify known properties, and 'id' does not exist...

出力:

/users/42/orders/ORD-7

Generic Pathは最初の引数からliteral typeとして推論されます。これによりparamsオブジェクトが正確にチェックされます。キーの不足、余分、誤入力はすべてエラーになります。関数本体でRecord<string, string>にキャストするのは必要です。TypeScriptは実行時のregexから取得されたキーがExtractParams<Path>に属することを証明できません。

7. 総合的な例:型安全なイベントエミッター

上記の技術を組み合わせると、注文ドメイン用のエミッターが実装できます:

type OrderEvents = {
  created: { orderId: string; total: number };
  shipped: { orderId: string; carrier: string };
  cancelled: { orderId: string; reason: string };
};

type EventHandlers<E> = {
  [K in keyof E & string as `on${Capitalize<K>}`]?: (payload: E[K]) => void;
};

class OrderEmitter {
  constructor(private readonly handlers: EventHandlers<OrderEvents>) {}

  emit<K extends keyof OrderEvents>(event: K, payload: OrderEvents[K]): void {
    const name = `on${event[0].toUpperCase()}${event.slice(1)}` as
      `on${Capitalize<K>}`;
    const handler = this.handlers[name] as
      ((p: OrderEvents[K]) => void) | undefined;
    handler?.(payload);
  }
}

const emitter = new OrderEmitter({
  onCreated: (p) => console.log(`Created ${p.orderId}: ${p.total}`),
  onShipped: (p) => console.log(`Shipped via ${p.carrier}`),
});

emitter.emit("created", { orderId: "ORD-1", total: 250_000 });
emitter.emit("shipped", { orderId: "ORD-1", carrier: "GHN" });
// emitter.emit("shipped", { orderId: "ORD-1", reason: "x" }); // Error

出力:

Created ORD-1: 250000
Shipped via GHN

得られるもの:

  • onCreated, onShipped, onCancelledというハンドラー名はOrderEventsから自動的に生成されます。
  • 各ハンドラー内のpパラメータは、宣言なしで正しいペイロードの型として推論されます。
  • emit("shipped", ...)は強制的にペイロードがcarrierを持つようにします。

emit内の2つのasは、コンパイラが自動的に推論できないことをコンパイラに伝える部分です。実行時に構築された文字列は型情報を持ちません。このようなキャストは実装の小さな部分に留めてください。パブリックAPIは依然として完全に型安全です。

8. Viestでの型テスト

型層のロジックもテストが必要です。そうしないと、リファクタリングでExtractParamsがstringに変わっても誰も気づかないかもしれません。Viestをインストールします:

npm install -D vitest typescript

型と実行時の両方の動作をテストします:

// route.test.ts
import { describe, it, expect, expectTypeOf } from "vitest";
import { buildUrl, type ExtractParams } from "./route";

describe("ExtractParams", () => {
  it("パラメータ名を正しく抽出する", () => {
    expectTypeOf<ExtractParams<"/users/:userId/orders/:orderId">>()
      .toEqualTypeOf<"userId" | "orderId">();
  });

  it("パラメータがない場合はneverを返す", () => {
    expectTypeOf<ExtractParams<"/health">>().toBeNever();
  });
});

describe("buildUrl", () => {
  it("パラメータを置き換えて値をエンコードする", () => {
    const url = buildUrl("/customers/:customerId", { customerId: "a b" });
    expect(url).toBe("/customers/a%20b");
  });

  it("間違ったキーを拒否する", () => {
    // @ts-expect-error - 'id'はこのルートのパラメータではありません
    buildUrl("/customers/:customerId", { id: "1" });
  });
});

実行:

npx tsc --noEmit && npx vitest run

expectTypeOfは実行時ではno-opです。エラーはtype-check時だけに現れるため、tsc --noEmitステップ(またはvitest --typecheck)が必須です。@ts-expect-errorも単なるassertionです。下の行に型エラーがなくなると、tscは*“Unused ‘@ts-expect-error’ directive”*と報告します。

9. よくある落とし穴と、使うべきでない場面

注意: Template literal typesはコンパイル時にのみ存在します。APIやフォーム、process.envからのデータはチェックされません。外部データに対しては、regexやスキーマライブラリでランタイム検証が必要です。

具体的な落とし穴:

  • ${number}は思ったより広い。 "1e3px"や"0.5px"も`${number}px`に有効です。「正の整数」が必要な場合、template literalでは表現できません。ランタイムでチェックしてください。
  • unionが爆発する。 セクション3のように、複数の大きなunionを連結するとコンパイラが大幅に遅くなり、複雑すぎるエラーが出ることもあります。
  • 深い再帰。 ExtractParamsのような再帰型には深さの制限があります。非常に長い文字列の場合、*“Type instantiation is excessively deep and possibly infinite”*エラーが出ます。通常のルートはこの制限に達しませんが、一般的な文字列パーサーなら達する可能性があります。
  • 読みにくいエラーメッセージ。 複雑な型の場合、エラーが数十行になることがあります。同僚がこんなエラーを毎日読まなければならないなら、利益より代価が大きくなります。
  • & stringの不足。 keyof Tはnumber | symbolを含む可能性があり、Capitalizeはそれらを拒否します。
  • 濫用。 値の集合が小さく固定されているなら、unionリテラルをそのまま書きましょう。type Size = "sm" | "md"と書く方が、複雑な自動生成型より読みやすいです。

真実のソース(interface、event object、ルート文字列)から他の型が自動推論される場合に使うべきです。スキルを見せるためだけに使うべきではありません。

10. まとめ

Template literal typesにより、文字列がTypeScriptの型システムで一級市民になります。本記事では主な技術を説明しました:

  • 文字列の連結とunionへの分配;
  • Uppercase, Capitalizeなどで大文字/小文字の変換;
  • asでmapped type内のキーを名前変更・フィルタリング;
  • inferを使った文字列抽出(再帰的パースを含む)。

ルートパラメータ、イベント名、getterなど、適切な場所で使用すれば、以前は実行時にしか発見できなかった誤った名前のエラーをなくせます。expectTypeOfと@ts-expect-errorで型をテストしてください。unionの複雑さと再帰の深さに気をつけてください。そして常に外部からのデータを検証してください。

typescripttemplate literal typestype systemgenericsvitest