11
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

# [TypeScriptシリーズ - Part 3] Template Literal Types

11
Last updated at Posted at 2026-04-20

📝 注記
私は日本語が得意ではありません。この記事はAIのサポートを受けて書いています。ご了承ください。


📖 目次

  1. 問題の提示 – どんな時にこのテクニックが必要か
  2. 悪い例 – まずはダメなコードを見せる
  3. 良い例 – TypeScriptの高度機能で解決する
  4. Playgroundリンク – その場で試せる
  5. 課題 – シニア向けのチャレンジ問題
  6. まとめ

1. 問題の提示 – どんな時にこのテクニックが必要か

あなたは makeWatchedObject という関数を実装しています。この関数はオブジェクトに on メソッドを追加し、プロパティの変更を監視できるようにします。

const person = makeWatchedObject({
  firstName: "Saoirse",
  lastName: "Ronan",
  age: 26
});

// こんな風に使いたい
person.on("firstNameChanged", (newName) => {
  console.log(`firstName was changed to ${newName}!`);
});

問題点:

  • イベント名は "firstNameChanged" のように プロパティ名 + "Changed" の形式
  • コールバックの引数 newNamefirstName の型(string)であるべき
  • しかし、on の型定義を間違えると any になってしまう
  • タイプミス("frstNameChanged")も検出できない
  • プロパティ名そのもの("firstName")もエラーにしたい

問いかけ:

どうすればイベント名の形式を強制し、かつコールバックの引数の型を自動的に推論できるでしょうか?


2. 悪い例 – まずはダメなコードを見せる

// ❌ 型安全じゃないon関数

function makeWatchedObject(obj: any): any {
  // 実装は省略
  return obj;
}

const person = makeWatchedObject({
  firstName: "Saoirse",
  lastName: "Ronan",
  age: 26
});

// 問題1: イベント名のスペルミスを検出できない
person.on("frstNameChanged", (newName) => {
  // 😱 "frstNameChanged" は存在しないがエラーにならない
  console.log(newName);
});

// 問題2: プロパティ名そのものも通ってしまう
person.on("firstName", (newName) => {
  // 😱 "firstName" も通ってしまう
  console.log(newName);
});

// 問題3: コールバックの引数がany
person.on("firstNameChanged", (newName) => {
  // newNameの型は any 😰
  console.log(newName.toUpperCase()); // 実行時エラーの可能性
});

// 問題4: ageの変更なのにstringが渡される可能性
person.on("ageChanged", (newAge) => {
  // newAgeの型は any、numberである保証がない
  console.log(newAge + 10); // newAgeがstringだったら "2610" になる
});

なぜ悪いのか:

問題 説明
型安全でない イベント名のスペルミスを検出できない
形式を強制できない "firstName" のような不正な名前も通る
コールバックの引数がany プロパティの実際の型と一致しない
保守性が低い オブジェクトにプロパティを追加しても、onの型は追従しない

3. 良い例 – TypeScriptの高度機能で解決する

基本: Template Literal Typesとは?

Template Literal Typesは、文字列リテラル型をテンプレートのように結合できます。

type World = "world";
type Greeting = `hello ${World}`;
// 結果: "hello world"

// ユニオンと組み合わせると、全ての組み合わせが生成される
type Color = "red" | "blue";
type Size = "small" | "large";
type ProductCode = `${Color}-${Size}`;
// 結果: "red-small" | "red-large" | "blue-small" | "blue-large"

ユースケース1: イベント名の形式を強制する

type PropEventSource<Type> = {
  on(eventName: `${string & keyof Type}Changed`, callback: () => void): void;
};

declare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>;

const person = makeWatchedObject({
  firstName: "Saoirse",
  lastName: "Ronan",
  age: 26
});

// ✅ OK: 正しいイベント名
person.on("firstNameChanged", () => {});

// ❌ エラー: プロパティ名そのものは許可されない
person.on("firstName", () => {});
// Argument of type '"firstName"' is not assignable to
// parameter of type '"firstNameChanged" | "lastNameChanged" | "ageChanged"'

// ❌ エラー: タイプミスも検出される
person.on("frstNameChanged", () => {});
// Argument of type '"frstNameChanged"' is not assignable to
// parameter of type '"firstNameChanged" | "lastNameChanged" | "ageChanged"'

ユースケース2: コールバックの引数の型を推論する

// Key extends keyof Type で、どのプロパティが変更されたかをキャプチャ
type PropEventSource<Type> = {
  on<Key extends string & keyof Type>(
    eventName: `${Key}Changed`,
    callback: (newValue: Type[Key]) => void
  ): void;
};

declare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>;

const person = makeWatchedObject({
  firstName: "Saoirse",
  lastName: "Ronan",
  age: 26
});

// ✅ newNameの型は自動的に string になる
person.on("firstNameChanged", (newName) => {
  console.log(newName.toUpperCase()); // ✅ stringのメソッドが使える
});

// ✅ newAgeの型は自動的に number になる
person.on("ageChanged", (newAge) => {
  console.log(newAge + 10);           // ✅ numberとして演算できる
  console.log(newAge.toUpperCase());  // ❌ エラー: numberにtoUpperCaseはない
});

処理の流れ(視覚モデル)

person.on("firstNameChanged", (newName) => { ... })
    │
    ▼
`${Key}Changed` と "firstNameChanged" をマッチング
    │
    ▼
Key = "firstName"
    │
    ▼
Type[Key] = Type["firstName"] = string
    │
    ▼
callback: (newValue: string) => void  ✅

ユースケース3: 文字列操作ユーティリティ(組み込み型)

TypeScriptには4つの組み込み文字列操作型があります。

type Greeting = "hello world";

type Shout    = Uppercase<Greeting>;     // "HELLO WORLD"
type Whisper  = Lowercase<Greeting>;     // "hello world"
type Capital  = Capitalize<Greeting>;    // "Hello world"
type Uncapital = Uncapitalize<Greeting>; // "hello world"

// 実用的な例: APIエンドポイントの自動生成
type Resource = "user" | "post" | "comment";
type Method   = "get" | "post" | "put" | "delete";

type APIEndpoint = `${Uppercase<Method>} /api/${Capitalize<Resource>}`;
// 結果:
// "GET /api/User"    | "POST /api/User"    | "PUT /api/User"    | "DELETE /api/User"    |
// "GET /api/Post"    | "POST /api/Post"    | ...
// "GET /api/Comment" | ...

ユースケース4: ルートパラメータの抽出

type ExtractRouteParams<T extends string> =
  T extends `${infer Start}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof ExtractRouteParams<`/${Rest}`>]: string }
    : T extends `${infer Start}:${infer Param}`
    ? { [K in Param]: string }
    : {};

type UserParams = ExtractRouteParams<"/users/:id">;
// 結果: { id: string }

type PostParams = ExtractRouteParams<"/users/:userId/posts/:postId">;
// 結果: { userId: string; postId: string }

ユースケース5: CSSのユーティリティ型

// スペーシングユーティリティ
type Spacing  = "xs" | "sm" | "md" | "lg" | "xl";
type Direction = "t" | "r" | "b" | "l" | "x" | "y" | "";

type SpacingClass = `m${Direction}-${Spacing}`;
// 結果: "m-xs" | "m-sm" | "m-md" | ... | "mt-xs" | "mt-sm" | ... | "mx-xs" | ...

// グリッドシステム
type Column     = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
type Breakpoint = "sm" | "md" | "lg" | "xl" | "xxl";

type GridClass = `col-${Breakpoint}-${Column}`;
// 結果: "col-sm-1" | "col-sm-2" | ... | "col-xl-12"

4. Playgroundリンク – その場で試せる

理論だけでは実感しにくいので、実際に動かして確認してみましょう。
TypeScript Playgroundはブラウザ上でTypeScriptを実行できる公式ツールです。インストール不要、すぐに試せます。

🔗 Playground URL: https://www.typescriptlang.org/play/

何を確認できるのか?

下のコードをコピーしてPlaygroundに貼り付けた後、person.on(...) の引数部分にカーソルを当ててみてください。許可されるイベント名の一覧がサジェストに表示されます。また newNamenewAge にホバーすると、それぞれ stringnumber と自動推論されているのが確認できます。

// ① このコードをコピーしてPlaygroundに貼り付ける
type PropEventSource<Type> = {
  on<Key extends string & keyof Type>(
    eventName: `${Key}Changed`,
    callback: (newValue: Type[Key]) => void
  ): void;
};

declare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>;

const person = makeWatchedObject({
  firstName: "Saoirse",
  lastName: "Ronan",
  age: 26
});

// ② newName / newAge にホバーして型を確認しよう
person.on("firstNameChanged", (newName) => {
  console.log(newName.toUpperCase());
});

person.on("ageChanged", (newAge) => {
  console.log(newAge + 10);
});

// 組み込み文字列操作型
type Shout   = Uppercase<"hello world">;
type Whisper = Lowercase<"HELLO WORLD">;
type Capital = Capitalize<"hello world">;

// ルートパラメータ抽出
type ExtractRouteParams<T extends string> =
  T extends `${infer Start}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof ExtractRouteParams<`/${Rest}`>]: string }
    : T extends `${infer Start}:${infer Param}`
    ? { [K in Param]: string }
    : {};

// ③ これらにホバーして抽出された型を確認しよう
type UserParams = ExtractRouteParams<"/users/:id">;
type PostParams = ExtractRouteParams<"/users/:userId/posts/:postId">;

ホバーすると何が見える?

newName にホバーすると stringnewAge にホバーすると number が表示されます。on の第一引数に "firstName" と入力するとエラーになり、"firstNameChanged" なら通ることも確認できます。


5. 課題 – シニア向けのチャレンジ問題

課題1: Getter型の自動生成

以下の Person インターフェースがあります。

interface Person {
  name: string;
  age: number;
  email: string;
}

Getters<T> を作成し、全てのプロパティに対して getPropertyName 形式のgetterを持つ型を生成してください。

💡 ヒント: Mapped Types の as 句 + Capitalize を使います。

✅ 解答を見る(クリック)
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type PersonGetters = Getters<Person>;
// 結果: {
//   getName:  () => string;
//   getAge:   () => number;
//   getEmail: () => string;
// }

課題2: Setter型の自動生成

Setters<T> を作成し、全てのプロパティに対して setPropertyName 形式のsetterを持つ型を生成してください。

💡 ヒント: 課題1と同じパターンで、戻り値を void、引数を T[K] にします。

✅ 解答を見る(クリック)
type Setters<T> = {
  [K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void;
};

type PersonSetters = Setters<Person>;
// 結果: {
//   setName:  (value: string) => void;
//   setAge:   (value: number) => void;
//   setEmail: (value: string) => void;
// }

課題3: イベント名のバリエーション

on メソッドを拡張し、"beforeChange""afterChange" の両方をサポートしてください。

// 使用例:
person.on("firstNameBeforeChange", (oldValue) => { /* ... */ });
person.on("firstNameAfterChange",  (newValue) => { /* ... */ });

💡 ヒント: on のオーバーロードを2つ定義します。

✅ 解答を見る(クリック)
type PropEventSource<Type> = {
  on<Key extends string & keyof Type>(
    eventName: `${Key}BeforeChange`,
    callback: (oldValue: Type[Key]) => void
  ): void;
  on<Key extends string & keyof Type>(
    eventName: `${Key}AfterChange`,
    callback: (newValue: Type[Key]) => void
  ): void;
};

課題4(ボーナス): オブジェクトからCSS変数を生成

以下のオブジェクトから、CSS変数名のユニオン型を生成してください。

const theme = {
  color: {
    primary: "#007bff",
    secondary: "#6c757d"
  },
  spacing: {
    small: "8px",
    medium: "16px"
  }
};

// 期待する結果:
// "--color-primary" | "--color-secondary" | "--spacing-small" | "--spacing-medium"

💡 ヒント: 再帰的なTemplate Literal Types + Mapped Typesを組み合わせます。

✅ 解答を見る(クリック)
type CSSVar<T, Prefix extends string = ""> = {
  [K in keyof T]: K extends string
    ? T[K] extends object
      ? CSSVar<T[K], `${Prefix}${K}-`>
      : `--${Prefix}${K}`
    : never;
}[keyof T];

type ThemeVars = CSSVar<typeof theme>;
// 結果: "--color-primary" | "--color-secondary" | "--spacing-small" | "--spacing-medium"

6. まとめ

今日学んだこと

技術 説明
Template Literal Types 文字列リテラル型をテンプレートのように結合
ユニオンとの組み合わせ 全ての組み合わせを自動生成
${infer Param} 文字列からパラメータ部分を抽出
Uppercase / Lowercase 文字列を大文字/小文字に変換(組み込み)
Capitalize / Uncapitalize 先頭文字を大文字/小文字に変換(組み込み)
Key Remapping with as Mapped Typesと組み合わせてキー名を変換

シニアへのアドバイス

Template Literal Typesは、文字列操作を型安全にする強力な機能です。ルーティング、イベントシステム、CSSフレームワークなどで真価を発揮します。
ただし、巨大なユニオンを生成するとコンパイル時間が増加するので注意しましょう。

Have a nice day! 🚀

11
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?