TypeScriptのテンプレートリテラル型:文字列の組み合わせとイベント名を型安全に扱う
TypeScriptのテンプレートリテラル型を実践コードで解説。文字列の組み合わせによるパスの型、keyofとマップ型によるイベント名の生成、Uppercase・Capitalize、外部入力の検証方法を学べます。
TypeScriptのテンプレートリテラル型を使うと、文字列リテラルを組み合わせて「許可する文字列の集合」を型で表せます。言語とページ名からパスを作ったり、プロパティ名からイベント名を作ったりする場面で、文字列の書き間違いを見つけやすくなります。
この記事では、前回紹介したkeyof・typeof・as constを一歩進めて、文字列の組み合わせ、型付きイベントハンドラー、外部入力の検証を実装します。掲載コードはTypeScript 4.9.4のstrictモードで型チェックと実行確認をしています。
1. テンプレートリテラル型は文字列から新しい型を作る
JavaScriptでは、バッククォートと${...}を使って文字列を組み立てられます。TypeScriptでは、同じような構文を型の位置に書くと、文字列リテラル型を組み合わせられます。
ファイル:src/examples/template-basics.ts
export type Language = "ja" | "en";
export type Page = "home" | "articles";
export type PagePath = `/${Language}/${Page}`;
// "/ja/home" | "/ja/articles" | "/en/home" | "/en/articles"
const path: PagePath = "/ja/articles";
console.log(path);
Languageの2候補とPageの2候補が組み合わさり、PagePathは4種類の文字列になります。"/ja/settings"のように定義していない組み合わせを代入すると、型エラーになります。
ここで作っているのは型です。実行時に4件の文字列配列が自動生成されるわけではありません。候補を画面に表示したい場合などは、次の節のように実際の配列も定義します。
参考:TypeScript Handbook — Template Literal Types
2. 定数からパスの候補と型を同時に作る
値と型を別々に手書きすると、候補を追加したときに更新漏れが起きます。as constで定義した配列を元にすれば、組み合わせる候補をひとつの場所で管理できます。
ファイル:src/lib/page-path.ts
export const languages = ["ja", "en"] as const;
export const pages = ["home", "articles"] as const;
export type Language = (typeof languages)[number];
export type Page = (typeof pages)[number];
export type PagePath = `/${Language}/${Page}`;
export function createPagePath(
language: Language,
page: Page,
): PagePath {
return `/${language}/${page}`;
}
const allowedPaths: ReadonlySet<string> = new Set(
languages.flatMap((language) =>
pages.map((page) => createPagePath(language, page)),
),
);
export function isPagePath(value: unknown): value is PagePath {
return typeof value === "string" && allowedPaths.has(value);
}
createPagePath()は、許可された言語とページ名からパスを生成します。引数と戻り値の両方に型を付けることで、利用側での入力ミスと、関数内での誤った文字列の組み立てを検出できます。
allowedPathsは、実行時の検証に使う集合です。languagesとpagesから実際に文字列を作っているため、候補の追加が型と検証用データの両方へ反映されます。
この例のパスは説明用の候補です。実際のアプリでは、ルーターが扱うパスと一致する定義に置き換えてください。
3. 外部から受け取った文字列は実行時に検証する
テンプレートリテラル型を書いても、フォーム入力やAPIの応答が自動的に検証されることはありません。外部の値はunknownとして受け取り、条件を確認してから扱います。
ファイル:src/examples/page-path-demo.ts
import { createPagePath, isPagePath } from "../lib/page-path";
const generated = createPagePath("ja", "articles");
console.log(generated); // /ja/articles
function showPagePath(input: unknown): string {
if (!isPagePath(input)) {
return "使用できないパスです";
}
// この分岐ではinputをPagePathとして扱える
return `移動先: ${input}`;
}
console.log(showPagePath("/en/home")); // 移動先: /en/home
console.log(showPagePath("/ja/settings")); // 使用できないパスです
value is PagePathは型述語です。戻り値がtrueのときに値をPagePathとして扱えることを、TypeScriptへ伝えています。ただし、コンパイラーが検証処理の正しさまで証明してくれるわけではありません。この例では候補集合への一致を確認し、実際の動作もテストします。
今回の判定は完全一致です。"/ja/articles/"のような末尾のスラッシュ付き文字列や、クエリ文字列が付いた値は受け付けません。URL全体を扱う場合は、アプリの仕様に合わせてパスの取り出しや正規化を行ってください。
input as PagePathと書くだけでは、このような検証は追加されません。外部入力を使うときは、型の宣言と実行時の検証を分けて考えましょう。
参考:TypeScript Handbook — Using type predicates
4. プロパティ名からイベントハンドラーの型を作る
記事のtitleが変わったらtitleChanged、viewsが変わったらviewsChangedを呼び出す、といった命名規則にも応用できます。
ファイル:src/lib/article-events.ts
export type Article = {
title: string;
views: number;
};
export type ChangeHandlers<T> = {
[K in keyof T as `${K & string}Changed`]?: (value: T[K]) => void;
};
export const handlers: ChangeHandlers<Article> = {
titleChanged: (value) => {
console.log(value.toUpperCase()); // valueはstring
},
viewsChanged: (value) => {
console.log(value.toFixed(0)); // valueはnumber
},
};
この型は、マップ型とキーの再マッピングを組み合わせています。
K in keyof Tで、元の型のキーを順番に扱います。as以降のテンプレートリテラル型で、キー名にChangedを付けます。K & stringで、ここでは文字列のキーを対象にします。T[K]で、元のプロパティに対応する値の型を取り出します。?で、各ハンドラーの定義を省略できるようにします。
titleChangedに渡す値はstring、viewsChangedに渡す値はnumberです。イベント名の規則と、そのイベントが扱う値の型を、同じ元の型から作れます。
呼び出し側もファイルを分けて確認できます。
ファイル:src/examples/article-events-demo.ts
import { handlers } from "../lib/article-events";
handlers.titleChanged?.("TypeScriptでイベント名を管理する");
handlers.viewsChanged?.(120);
?.を使っているのは、ハンドラーを省略可能にしているためです。handlers.viewsChanged?.("120")のように数値用のハンドラーへ文字列を渡すと型エラーになります。
この仕組みが作るのはハンドラーの型です。プロパティの変更検知やイベントの自動配信までは実装していません。画面やデータの更新時にハンドラーを呼び出す処理は、アプリ側で用意します。
参考:TypeScript Handbook — Key Remapping via as
5. 文字の変換には組み込みの文字列操作型を使う
TypeScriptにはUppercase、Lowercase、Capitalize、Uncapitalizeという文字列操作型があります。テンプレートリテラル型と組み合わせると、一定の命名規則も型にできます。
ファイル:src/examples/string-names.ts
type Section = "user" | "article";
type EnvironmentKey = `APP_${Uppercase<Section>}`;
// "APP_USER" | "APP_ARTICLE"
type GetterName = `get${Capitalize<Section>}`;
// "getUser" | "getArticle"
const environmentKey: EnvironmentKey = "APP_ARTICLE";
const getterName: GetterName = "getUser";
console.log(environmentKey, getterName);
Uppercaseは文字列型を大文字へ、Capitalizeは先頭文字を大文字へ変換します。これらも型の操作なので、実行時の文字列を変更する処理ではありません。
ユーザーに見せる自然言語の大文字・小文字変換とは用途を分けてください。TypeScriptの組み込み文字列操作型は、ロケールを考慮した変換ではありません。
6. 候補を増やしすぎない
ユニオン型を複数箇所に埋め込むと、候補の組み合わせ数が増えます。たとえば10言語、20ページ、5表示モードをすべて組み合わせると、最大で1,000通りになります。
候補が少なく、アプリの仕様として固定されている場合には便利です。一方、大量のIDや自由入力をすべて文字列リテラル型として表そうとすると、型が大きくなりすぎることがあります。公式ドキュメントでも、大きな文字列ユニオンは事前生成を検討するよう案内されています。
型で表す範囲は、レビューしやすさとエディターの扱いやすさも含めて決めましょう。自由入力の形式や範囲の検証には、実行時の処理を組み合わせます。
7. サンプルをファイルに保存して確認する
この記事のサンプルは、次の構成で保存できます。
src/
lib/
page-path.ts
article-events.ts
examples/
template-basics.ts
page-path-demo.ts
article-events-demo.ts
string-names.ts
TypeScriptがインストール済みのプロジェクトで、次のコマンドを実行します。src/lib/の2ファイルは、サンプルからのimportを通して読み込まれます。
npx tsc --strict --noEmit --target ES2020 --module commonjs --skipLibCheck src/examples/template-basics.ts src/examples/page-path-demo.ts src/examples/article-events-demo.ts src/examples/string-names.ts
ファイルを明示したこのコマンドでは、tsconfig.jsonは読み込まれません。既存プロジェクトの設定で全体を確認したい場合は、tsconfig.jsonがあるディレクトリでnpx tsc --noEmitを実行してください。
よくある疑問
テンプレートリテラル型と正規表現は同じですか?
異なります。テンプレートリテラル型はコンパイル時の型検査に使います。正規表現は、JavaScriptの実行時に文字列を調べる用途などに使います。型を定義するだけで入力値が検証されるわけではありません。
キーの再マッピングに使うasは型アサーションですか?
ここでのasは、マップ型のキーを別の名前へ変換する構文です。input as PagePathのように値を別の型として扱う型アサーションとは役割が異なります。
数値やシンボルのキーもイベント名になりますか?
今回のChangeHandlers<T>はK & stringを使うため、文字列のキーを対象にしています。数値やシンボルのキーも扱いたい場合は、その用途に合わせて型と命名規則を設計する必要があります。