【実務・中級編】引数に渡す「文字列リテラル型」の自動補完を効かせるための型定義のコツ – TypeScript コア・型システムの基礎解析バイブル

開発現場のコードレビューをしていると、次のようなコードに頻繁に出くわす。

// 良くあるアンチパターン
type Theme = ‘light’ | ‘dark’ | ‘system’ | string; // 完全に型の意味が死んでいる

「IDEの補完が効かない」「将来の拡張性に備えて`string`を逃がしとして入れておいた」――。
この妥協が生むのは、型安全性の崩壊と、開発体験(DX)の静かなる死だ。`string`を混ぜた瞬間、TypeScriptの強力な推論エンジンは白旗を上げ、IDEの補完候補は闇に消える。

今回は、「Union型の厳密性を保ったまま、任意の文字列も許容し、かつIDEの自動補完を一切殺さない」という、実務の最前線で求められる高度な型設計のテクニックを伝授しよう。コンパイラがどう型を評価しているのか、そのメカニズムから紐解いていく。

—

なぜ、ただの `string | Literal` では補完が死ぬのか?

まず、TypeScriptの型システムにおける「プリミティブ型とリテラル型」の衝突を理解する必要がある。

type Variant = ‘primary’ | ‘secondary’ | string;

この型を持つ引数に対してIDEでドットやクォートを入力しても、TypeScript言語サーバーは `’primary’` や `’secondary’` を優先的にサジェストしてくれない。なぜなら、`string`という無限の集合がUnionに含まれた時点で、コンパイラは「これは実質的にただの`string`である」と判定し、リテラル型の持つメタデータを縮小化(Widening)してしまうからだ。

私たちが目指すゴールは以下の2点である。
1. 定義済みの文字列リテラル(例: `’sm’ | ‘md’ | ‘lg’`)については、IDEで強烈に補完が効くこと。
2. しかし同時に、デザインシステム外のカスタム値や、将来的な拡張として任意の文字列(例: `’2rem’` や `custom-val`)もコンパイルエラーなく渡せること。

これを実現するアプローチが、「Wideningのハック(お馴染みの `(string & {})` 技巧)」である。

—

実装パターン:最強のオートコンプリート・ハック

百聞は一見にしかず。まずはプロダクションコードとしてそのまま使える実装を見てほしい。

/

  • TypeScriptの型システムをハックし、補完と柔軟性を両立させるためのユーティリティ型

/
type LiteralUnion = T | (U & {});

// — ユースケース:コンポーネントのサイズ指定 —

// 1. 基底となるリテラル型を定義
type BaseSize = ‘sm’ | ‘md’ | ‘lg’ | ‘full’;

// 2. LiteralUnionでラップする
type ComponentSize = LiteralUnion;

interface BoxProps {
// ここに ComponentSize を指定する
size: ComponentSize;
padding?: number;
}

declare function Box(props: BoxProps): void;

// — 開発時の挙動 —
// ① ‘sm’, ‘md’, ‘lg’, ‘full’ は当然のようにIDEで完璧に候補としてサジェストされる
Box({ size: ‘md’ });

// ② 型定義にない任意の文字列を渡しても、コンパイルエラーにならず通る!
Box({ size: ’25rem’ });
Box({ size: ‘calc(100vh – 50px)’ });

なぜ `(U & {})` なのか?(コンパイラ内部の挙動)

ここがこのアーキテクチャの核心だ。
単に `T | string` と書くと、前述の通りTypeScriptの型推論器は `string` の広大さに飲まれてリテラルのヒントを捨ててしまう。

しかし、`(string & {})` という「一見すると無意味な交差型(Intersection)」を挟むとどうなるか?

  • `{}`(空オブジェクト)との交差型は、実行時には単なる `string` と全く同じ挙動をする。
  • しかし、コンパイラの型推論器のアルゴリズムにおいて、これは「直接的なプリミティブ型 `string`」として扱われない。
  • その結果、TypeScriptは `T`(リテラル型)の補完候補を維持したまま、任意の文字列も型チェックを通過させることができるようになる。

これが、コンパイラの裏をかき、DXを最大化するプロの型設計だ。

—

実務応用:APIのエンドポイントやカスタムイベントでの活用

このテクニックは、単なるUIのサイズ指定にとどまらず、非同期APIクライアントのパス補完や、イベントエミッターの型定義で真価を発揮する。

// 定義済みの主要なAPIエンドポイント
type PredefinedEndpoints =
| ‘/api/v1/users’
| ‘/api/v1/posts’
| ‘/api/v1/auth/login’;

// 任意の動的パスや、一時的なモックエンドポイントも許容する
type ApiEndpoint = LiteralUnion;

interface FetchOptions {
method: ‘GET’ | ‘POST’ | ‘PUT’ | ‘DELETE’;
}

async function apiClient(endpoint: ApiEndpoint, options: FetchOptions) {
// 処理本体
return fetch(endpoint, { method: options.method }).then(res => res.json());
}

// — 使用例 —

// 1. 主要なAPIは完璧に補完される
apiClient(‘/api/v1/users’, { method: ‘GET’ });

// 2. 動的なID付きパスや、まだ型定義されていない新機能のエンドポイントも弾かれない
apiClient(‘/api/v1/posts/9981-uuid-string’, { method: ‘GET’ });

もしここで `ApiEndpoint` に `LiteralUnion` を使っていなければ、開発者は新しいAPIを追加するたびに型定義ファイルを開いてUnionを書き足す苦行を強いられるか、あるいは安全性を捨てて単なる `string` に堕落させるしかなかったはずだ。

—

コードレビューでのチェックポイント:いつこのパターンを使うべきか?

テクニカルリードとして、チームメンバーがこのパターンを導入する際は以下の点をレビューで厳しく確認してほしい。

1. 「完全に閉じたドメイン」には使わないこと

  • 例えば、ステートマシーンの状態(`’idle’ | ‘loading’ | ‘success’ | ‘error’`)のように、「絶対に外側の文字列を受け入れてはならない(網羅性チェック / Exhaustiveness Checkが必要な場合)」には、`LiteralUnion` を使ってはならない。その場合は通常のUnion型を使い、`switch` 文や `never` 型による網羅性担保を行うべきだ。

2. 「拡張性が求められる開いたインターフェース」に限定すること

  • デザイントークン、CSSの単位、APIパス、イベント名など、「基本は用意されているが、ユーザーが独自のカスタム値を注入する余地を残したい」というユースケースにこそ、この `LiteralUnion` を投入する。

—

まとめ

TypeScriptの型は、単にバグを防ぐための静的解析ツールではない。「最高の開発体験(DX)を提供し、IDEを最高の相棒にするためのインターフェース設計言語」である。

「網羅性を強制する厳格なUnion」と「拡張性を担保するLiteralUnion」。この二つをコンテキストに応じて自在に使い分けられるかどうかが、ジュニアから、プロダクト全体のアーキテクチャを牽引するシニアエンジニアへと殻を破るための境界線となる。

今日のコードから、無意味な `string` の混入を断ち切り、型に意思を持たせよう。

タイトルとURLをコピーしました