TypeScript 上級型実践:条件付き型から型レベル・プログラミングへ

前端工程

あるリファクタリングが、型への見方を変えた

昨年、管理画面のプロジェクトでフィールド名を変更した。userProfileavatarUrlavatar に改名する作業だ。たかが名前の変更だと思っていた——しかし TypeScript が 31 ファイルを一斉に赤く塗りつぶした。CDN の接頭辞をつなげていた箇所、null のフォールバックを入れていた箇所、<img src> に渡していた箇所、すべてをコンパイラが教えてくれた。

もしこれが JavaScript なら、こうしたエラーは実行時、場合によってはユーザーのブラウザで初めて顔を出しただろう。あの夜、私はひとつ学んだ。型はコストではない。コンパイラはあなたのために無料のコードレビューをしてくれているのだ

この記事では基本文法は省く。interface やジェネリクスには慣れている前提で、最初は怖そうで、使いこなすと手放せなくなる上級型に直行する。


1. 土台を固める:keyof とインデックスアクセス

多くの上級テクニックは 2 つの原初的な機能に支えられている。

interface User {
  id: number;
  name: string;
  email: string;
}

type UserKeys = keyof User; // "id" | "name" | "email"

type IdType = User["id"];   // number
type NameOrEmail = User["name" | "email"]; // string

keyof T はすべてのキーの union を、T[K] はインデックスアクセスを返す。組み合わせれば「キーから対応する値の型を取り出す」ヘルパーが書ける:

function pick<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user: User = { id: 1, name: "Ada", email: "a@x.com" };
const name = pick(user, "name"); // 型は string と推論される

2 つ目のジェネリクス K extends keyof T が効いている点に注意——これは呼び出し側が存在しないキーを渡すのを防ぐ制約だ。

値に対する typeof

typeof を型の位置で使うと、からを逆算できる。実行時のコードから型を抜き出すのに最適で、二重宣言を避けられる:

const config = {
  apiBase: "https://api.example.com",
  timeout: 8000,
  retries: 3,
};

type Config = typeof config;
// { apiBase: string; timeout: number; retries: number }

2. 条件付き型:型の三項演算子

形は T extends U ? X : Y で、「T が U に代入可能なら X、そうでなければ Y」と読む。

type IsArray<T> = T extends unknown[] ? true : false;

type A = IsArray<string[]>; // true
type B = IsArray<string>;   // false

標準ライブラリの ExtractExclude も条件付き型で書かれている。自分で実装してみると、ドキュメントを 10 回読むより染みる:

type MyExtract<T, U> = T extends U ? T : never;
type MyExclude<T, U> = T extends U ? never : T;

type T0 = MyExtract<"a" | "b" | "c", "a" | "c">; // "a" | "c"
type T1 = MyExclude<"a" | "b" | "c", "a" | "c">; // "b"

重要な洞察:T が union のとき、条件付き型は各メンバーごとに評価され、その結果が union される。これこそが MyExclude がメンバーを一つずつ除ける理由だ。

infer:型の中から「一部を取り出す」

inferextends の分岐内でしか使えない。「ここは決め打ちせず、コンパイラに推論させる」という意味だ。型に「分解」の能力を与える。

type First<T extends unknown[]> = T extends [infer Head, ...unknown[]]
  ? Head
  : never;

type F = First<[string, number, boolean]>; // string

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function loadUser() {
  return { id: 1, name: "Ada" };
}
type Loaded = MyReturnType<typeof loadUser>; // { id: number; name: string }

infer は戻り値、引数、Promise の内側、配列要素など、どこにでも置ける。Awaited<T> はこれで Promise の層を剥いでいる:

type MyAwaited<T> = T extends Promise<infer U>
  ? U extends Promise<unknown>
    ? MyAwaited<U>
    : U
  : T;

type R = MyAwaited<Promise<Promise<number>>>; // number

3. マップ型とキー再マッピング

マップ型は「型のすべてのキーを巡回」する。オブジェクトへの map のようなものだ。

type PartialUser = {
  [K in keyof User]?: User[K];
};
// { id?: number; name?: string; email?: string }

+/- 修飾子で readonly? の付け外しができる:

type Writable<T> = {
  -readonly [K in keyof T]: T[K];
};

キー再マッピング(TypeScript 4.1 以降)

as 句を使うとマップ中にキー名を書き換えられる。getter/イベント変換の肝だ:

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

type UserGetters = Getters<User>;
// {
//   getId: () => number;
//   getName: () => string;
//   getEmail: () => string;
// }

Exclude と組み合わせれば、特定のキーを「除外」することもできる:

type MethodsOnly<T> = {
  [K in keyof T as T[K] extends Function ? K : never]: T[K];
};

as の結果が never になったキーはそのまま落ちる。型を削るのに非常に重宝する。


4. テンプレートリテラル型

テンプレートリテラル型は文字列リテラルを連結できる。イベント名、ルーティング、CSS プロパティの制約に向いている:

type Lang = "zh" | "en" | "ja";
type Path = `/i18n/${Lang}`; // "/i18n/zh" | "/i18n/en" | "/i18n/ja"

type EventName = `on${Capitalize<"click" | "change" | "submit">}`;
// "onClick" | "onChange" | "onSubmit"

実践的な、型安全な CSS クラス setter:

type Spacing = "sm" | "md" | "lg";
type MarginClass = `m-${Spacing}` | `mt-${Spacing}` | `mb-${Spacing`;

function cx(...classes: MarginClass[]) {
  return classes.join(" ");
}
cx("mt-md", "mb-lg"); // OK
// cx("padding-sm");   // エラー:許可された集合にない

こうした制約はコンポーネントライブラリや DSL、設定バリデーションで真価を発揮する——スペルミスをコンパイル時に弾ける。


5. 再帰型

型は再帰的に書ける。readonlyobject 判定を組み合わせれば、深い不変構造を表現できる:

type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

const cfg: DeepReadonly<User> = { id: 1, name: "Ada", email: "a@x.com" };
// cfg.name = "Bob"; // エラー:読み取り専用プロパティに代入できない

DeepPartial も書いておこう。ユーザーが一部のフィールドしか埋めない「下書きフォーム」に便利だ:

type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

JSON シリアライズ型も再帰の古典:

type Json =
  | string
  | number
  | boolean
  | null
  | Json[]
  | { [key: string]: Json };

6. 実践:クエリ文字列を型レベルで解析する

型体操の有名な問題がある。"a=1&b=2&c=3" という文字列を { a: string; b: string; c: string } 型に解析する問題だ。完全な実装は長いが、ここでは動き、かつ読みやすい版を載せる。考え方は「& で分割、= で分割、順に蓄積」:

type Split<S extends string, D extends string> =
  string extends S ? string[]
  : S extends `${infer Head}${D}${infer Tail}`
    ? [Head, ...Split<Tail, D>]
    : [S];

type ParsePair<P extends string> =
  P extends `${infer K}=${infer V}` ? { [k in K]: V } : {};

type Merge<T> =
  (T extends any ? (k: T) => void : never) extends (k: infer U) => void
    ? U
    : never;

type ParseQueryString<S extends string> =
  Merge<ParsePair<Split<S, "&">[number]>>;

type Q = ParseQueryString<"a=1&b=2&c=3">;
// { a: "1" } & { b: "2" } & { c: "3" } の交差、すなわち合体したもの

この問題の価値は「答えを覚える」ことではない。infer と再帰、as 再マッピング、[number] でのメンバー取得を総動員させられる点にある。答えを見る前に自分で書いてみてほしい。


7. 型と実行時の境界

型はコンパイル後に消去されるため、「型で行った判定」は実行時には存在しない。実行時の検証が必要なときの二人の相棒:

型ガード

function isUser(v: unknown): v is User {
  return (
    typeof v === "object" &&
    v !== null &&
    "id" in v &&
    "name" in v
  );
}

satisfies:値は正しく、リテラル型は残す

satisfies(4.9 導入)は値を型に対して検査するが、型を広げない:

const routes = {
  home: "/",
  user: "/users/:id",
} satisfies Record<string, string>;

type RouteName = keyof typeof routes; // "home" | "user"(string ではない)

as と違い、satisfies は「検査はするが書き換えない」ため、ずっと安全だ。

zod との併用

実行時の検証も必要なら、zod のようなライブラリで schema から z.infer で型を逆算すれば、定義を一つにまとめられる:

import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
});

type User = z.infer<typeof UserSchema>; // schema と常に同期する

8. パフォーマンスの落とし穴:インスタンス化の深さ超過

型が複雑になるほど、コンパイラの仕事は増える。条件付き型で深く再帰すると error TS2589: Type instantiation is excessively deep and possibly infinite にぶつかることがある。

緩和策:

  • 巨大なタプルチェーンより配列インデックスを優先するT extends [infer H, ...infer R] は入れ子の三項演算子より安定することが多い。
  • 再帰には必ず終端条件を付け、各ステップで型が「小さく」なるようにする。
  • 意味のない深いマップは避け、必要なら中間型に分割してコンパイラが段階解決できるようにする。
  • TS2589 に本当に遭ったら、まず「実行時のロジックを型で書き直していないか」を自問する。型システムはあえてチューリング完全にはしていない——無理にループをシミュレートしても割に合わない。

9. API レスポンスを型でモデル化する

これを実戦に使う:fetch を型安全なクライアントで包み、「成功/失敗」を型レベルで分岐させる。

type ApiResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string };

async function getJson<T>(url: string): Promise<ApiResult<T>> {
  try {
    const res = await fetch(url);
    if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
    const data = (await res.json()) as T;
    return { ok: true, data };
  } catch (e) {
    return { ok: false, error: String(e) };
  }
}

interface Order { id: number; total: number }

const r = await getJson<Order>("/api/orders/1");
if (r.ok) {
  console.log(r.data.total); // 絞り込み後に安全にアクセス
} else {
  console.error(r.error);
}

「失敗」を例外ではなく明示的な分岐にすると、呼び出し側はエラー処理を強いられる。any を野放しにするよりずっと堅牢だ。


よくある質問

Q1:型体操を学ぶ価値はある?

業務 CRUD を書くなら、条件付き型、infer、マップ型、テンプレートリテラル型の 4 つを押さえればケースの 9 割はカバーできる。本格的な「体操問題」は思考訓練の側面が大きい。チームのコードを難解にしてまで見せびらかしてはいけない——可読性も型の一部だ。

Q2:interface と type の違いは今もある?

日常使いではほぼ互換。違いは:interface は宣言のマージ(同名で統合)が効く。type は union、条件、マップが書ける。私の流儀:オブジェクトの「形」は interface、型の「変換」は type

Q3:strict モードは全員オンにすべき?

強く推奨する。strict: truenoImplicitAnystrictNullChecks などを一括で有効化できる。短期的には直すべきエラーが増えるが、長期的には undefined is not a function の一类を一掃できる。

Q4:ジェネリクスの extends は継承と同じ?

違う。T extends Foo はここでは「T は Foo に代入可能でなければならない」という意味で、OOP の extends と同名なだけ。地形「〜に制約される」と読むと迷わない。


おすすめツール

型を書いたり JSON と付き合ったりするとき、ToolsKu の以下のツールが手間を省く:


型システムの終着点は「コンパイラを黙らせる」ことではない。「ミスをする前に口を開かせる」ことだ。型を看守ではなく、同僚として扱おう。

ブラウザローカルツールを無料で試す →

#TypeScript#类型系统#类型体操#泛型#前端工程