typescript-advanced-patterns について
コンパイル時の制約によって実行時エラーを減らす、高度な型システムパターンを扱う TypeScript 開発者向けスキルです。ブランド型、ユニオン、スキーマ推論などを解説し、単純な型を選ぶべき場面にも触れます。
主な機能
- 01ブランド型と名目的型付け
- 02判別可能ユニオンと網羅性チェック
- 03テンプレートリテラル型と条件型
- 04Zod スキーマ推論
- 05型安全なイベントエミッターパターン
活用シーン
- 01異なる識別子の混同防止
- 02API 結果や状態バリアントのモデル化
- 03検証済み外部データからの型導出
- 04型付きイベントを持つエミッターの構築
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ curiositech/ some_claude_skills --skill typescript-advanced-patternsインストール後、Claude Code で/typescript-advanced-patternsを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
.claude-plugin/plugin.jsonreferences/branded-types.mdtype-safe-patterns.md詳細
TypeScript の高度なパターン
コンパイル時に制約をエンコードすることで、実行時のバグをなくす高度な型システムのパターンです。
使用するタイミング
次のような場合に有効化: 「ブランド型」「名目的型付け」「判別共用体」「テンプレートリテラル型」「条件型」「infer キーワード」「satisfies 演算子」「const アサーション」「Zod の型推論」「網羅性のある switch」「型安全なイベントエミッター」「マップ型」「ユーティリティ型」「ジェネリック制約」「型の絞り込み」「as const」
対象外: TypeScript の基本構文 | React コンポーネントのプロパティ型 | JavaScript の一般的なパターン
問題に適した高度なパターンを選ぶための判断ツリー
flowchart TD
P[What is your problem?] --> P1[I'm mixing up\nvalues of the same type\ne.g. UserId vs OrderId]
P[What is your problem?] --> P2[I have a value that\ncan be one of N shapes]
P[What is your problem?] --> P3[I need type-safe\nevent emitting]
P[What is your problem?] --> P4[I'm parsing external\ndata and want types]
P[What is your problem?] --> P5[I want types that\ndepend on other types]
P[What is your problem?] --> P6[My switch/if is\nnot exhaustive]
P1 --> S1[Branded Types\nfor nominal typing]
P2 --> S2[Discriminated Unions\nwith type narrowing]
P3 --> S3[Type-Safe Event Emitter\nwith mapped types]
P4 --> S4[Zod schema + z.infer\nfor runtime + compile time]
P5 --> S5[Conditional Types\nwith infer keyword]
P6 --> S6[Exhaustive checking\nwith never type]
主なパターン
1. ブランド型(名目的型付け)
TypeScript の構造的型システムでは、UserId と string は互いに代入可能です。ブランド型はファントムブランドを追加し、これらを互換性のない型にします。
// Without branding: these compile without error
function chargeUser(userId: string, amount: number) { /* ... */ }
const orderId = getOrderId();
chargeUser(orderId, 100); // Wrong! orderId passed as userId — TypeScript allows it
// With branding: compile-time protection
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type EmailAddress = Brand<string, 'EmailAddress'>;
type Cents = Brand<number, 'Cents'>; // Never pass raw numbers as money
// Constructor functions (only way to create branded values)
const UserId = (id: string): UserId => id as UserId;
const OrderId = (id: string): OrderId => id as OrderId;
const Cents = (n: number): Cents => {
if (!Number.isInteger(n) || n < 0) throw new Error(`Invalid cents: ${n}`);
return n as Cents;
};
// Usage: type error if you mix them up
function chargeUser(userId: UserId, amount: Cents) { /* ... */ }
const uid = UserId('user_123');
const oid = OrderId('order_456');
chargeUser(uid, Cents(1000)); // OK
chargeUser(oid, Cents(1000)); // Error: Argument of type 'OrderId' is not assignable to 'UserId'
chargeUser(uid, 1000); // Error: Argument of type 'number' is not assignable to 'Cents'
Zod との統合やデータベースモデルのパターンについては、references/branded-types.md を参照してください。
2. 型の絞り込みを伴う判別共用体
kind(または type、tag)フィールドが判別子です。これをチェックすると、TypeScript は共用体を絞り込みます。
type ApiResult<T> =
| { status: 'success'; data: T }
| { status: 'error'; code: number; message: string }
| { status: 'loading' };
function handleResult<T>(result: ApiResult<T>): T | null {
switch (result.status) {
case 'success': return result.data; // TypeScript knows data exists here
case 'error': console.error(result.code, result.message); return null;
case 'loading': return null;
}
}
網羅性チェックを行う場合 — 新しいバリアントが追加され、switch が更新されていないと、コンパイルエラーになります。
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
}
function handleResult<T>(result: ApiResult<T>): T | null {
switch (result.status) {
case 'success': return result.data;
case 'error': return null;
// If 'loading' is not handled, TypeScript errors:
// Argument of type '{ status: "loading" }' is not assignable to 'never'
default: return assertNever(result);
}
}
3. テンプレートリテラル型
型レベルで文字列リテラルを組み合わせ、文字列ベースの API に対して厳密な型を作成します。
// Route path types — prevents typos in route definitions
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
type ApiVersion = 'v1' | 'v2';
type Resource = 'users' | 'orders' | 'products';
type ApiEndpoint = `/${ApiVersion}/${Resource}`;
// Type: "/v1/users" | "/v1/orders" | "/v1/products" | "/v2/users" | ...
// CSS-in-JS property types
type CSSProperty = 'margin' | 'padding' | 'border';
type Side = 'top' | 'right' | 'bottom' | 'left';
type CSSPropertyWithSide = `${CSSProperty}-${Side}`;
// Type: "margin-top" | "margin-right" | ... | "border-left"
// Event name types
type DOMEventMap = {
click: MouseEvent;
keydown: KeyboardEvent;
input: InputEvent;
};
type EventName = `on${Capitalize<keyof DOMEventMap>}`;
// Type: "onClick" | "onKeydown" | "onInput"
4. infer を使った条件型
条件型の評価中に、infer は別の型の内部から型を抽出します。
// Extract the return type of an async function
type Awaited<T> = T extends Promise<infer U> ? U : T;
type UserFetch = () => Promise<{ id: string; name: string }>;
type User = Awaited<ReturnType<UserFetch>>;
// Type: { id: string; name: string }
// Extract the element type of an array
type ElementOf<T> = T extends (infer U)[] ? U : never;
type Names = ElementOf<string[]>; // string
type Events = ElementOf<Array<{ id: number; type: string }>>; // { id: number; type: string }
// Extract handler parameters from an event map
type HandlerParams<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never;
// Deeply unwrap Promises
type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T;
type Result = DeepAwaited<Promise<Promise<string>>>; // string
5. satisfies 演算子(TypeScript 4.9+)
satisfies は値の型を拡大せずに、型に対して値を検証します。型チェックと、最も具体的な推論型の両方が得られます。
type Color = string | [number, number, number];
// Problem with 'as': loses specific type info
const colors = {
red: [255, 0, 0] as Color, // type is Color, not [255, 0, 0]
blue: [0, 0, 255] as Color,
} as Record<string, Color>;
// Problem with annotation: same widening
const colors2: Record<string, Color> = {
red: [255, 0, 0], // type is Color
};
// satisfies: validates AND preserves specific type
const colors3 = {
red: [255, 0, 0],
blue: [0, 0, 255],
} satisfies Record<string, Color>;
colors3.red.map(x => x * 2); // OK: TypeScript knows it's [number, number, number]
colors3.blue[0]; // OK: indexed access works
// Great for config objects
const config = {
port: 3000,
host: 'localhost',
debug: false,
} satisfies {
port: number;
host: string;
debug: boolean;
timeout?: number; // optional fields allowed to be absent
};
config.port.toFixed(0); // OK: port is still `number`, not widened to `number | string`
6. Zod スキーマの型推論
Zod は実行時に検証し、TypeScript の型も自動で提供します。型とバリデーターを別々に記述しないでください。
import { z } from 'zod';
// Define schema once
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(['admin', 'user', 'moderator']),
createdAt: z.coerce.date(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
// Extract type — no duplicate type definition
type User = z.infer<typeof UserSchema>;
// Type: { id: string; email: string; role: "admin" | "user" | "moderator"; createdAt: Date; metadata?: Record<string, unknown> }
// Parse with validation
function parseUser(raw: unknown): User {
return UserSchema.parse(raw); // throws ZodError with structured errors on failure
}
// Safe parse (returns Result type)
const result = UserSchema.safeParse(rawData);
if (result.success) {
result.data.role; // narrowed to User
} else {
result.error.issues; // structured validation errors
}
// Derive related schemas
const CreateUserInput = UserSchema.omit({ id: true, createdAt: true });
type CreateUserInput = z.infer<typeof CreateUserInput>;
const UpdateUserInput = UserSchema.partial().required({ id: true });
型安全なイベントエミッター、ビルダーパターン、網羅性チェック用ユーティリティについては、references/type-safe-patterns.md を参照してください。
リファレンスファイル
| ファイル | 内容 |
|---|---|
references/branded-types.md | ID/通貨/メールアドレス向けのブランド付きプリミティブ、Zod との統合、データベースのパターン |
references/type-safe-patterns.md | 網羅性のある switch、ビルダーパターン、型安全なイベントエミッター、マップ型ユーティリティ |
アンチパターン(見識の指標)
アンチパターン 1: 適切なジェネリックの代わりに any を逃げ道として使う
初心者の考え方: 「型を付けるには複雑すぎるので、とりあえず any を使って、あとで対応しよう。」
なぜ誤りなのか: any はその値に対するすべての型チェックを無効にし、さらにその値が関わるあらゆる箇所に広がります。any は伝染します。値がいったん any になると、それを受け取る関数は戻り値の型も any と推論され、時間とともに拡大する型の穴ができます。「あとで対応する」ことは決してありません。
検出方法: コードベースにある as any は、ほぼ常に TypeScript の制限ではなく、型設計の問題を示しています。
解決策 — 制約付きジェネリックを使う:
// Bad: uses any to avoid figuring out the right type
function processItems(items: any[]): any[] {
return items.filter(item => item.active);
}
// Good: generic with constraint
function processItems<T extends { active: boolean }>(items: T[]): T[] {
return items.filter(item => item.active);
}
// Better: if you just need "has a property", use unknown and narrow
function getProperty(obj: unknown, key: string): unknown {
if (typeof obj === 'object' && obj !== null && key in obj) {
return (obj as Record<string, unknown>)[key];
}
return undefined;
}
解決策 — 本当に不明なデータには any ではなく unknown を使う:
// Bad: any propagates
function parseData(raw: any) {
return raw.user.id; // No error, will crash if shape is wrong
}
// Good: unknown forces you to narrow
function parseData(raw: unknown) {
if (
typeof raw === 'object' &&
raw !== null &&
'user' in raw &&
typeof (raw as any).user === 'object'
) {
// Now you can access safely
}
// Better: use Zod
return UserSchema.parse(raw);
}
見識の指標: any は「わからないし、TypeScript にチェックさせたくない」という意味です。unknown は「わからないが、使う前にチェックする」という意味です。API 境界では unknown を優先し、any は決して使わないでください。
アンチパターン 2:よりシンプルな型で十分なのに、型を過剰に設計する
初心者の考え方(TypeScript に詳しい場合):「条件付き型とテンプレートリテラル型を使えば、ビジネスルール全体を型システムで表現できます!」
なぜ間違いなのか:型の複雑さには、実際のコストが伴います。infer の階層が複数ある条件付き型のエラーメッセージは、読み解くのに 3 分かかることがあります。若手エンジニアには保守できません。IDE の自動補完は非常に遅くなります。型の計算に 200ms かかると、ファイル全体の編集体験が損なわれます。
判断のための問い:型の複雑さによって、実際に起こり得る種類のバグを防げますか? そうであれば、採用する価値があります。単に実現できることを証明したいだけなら、シンプルにしましょう。
適切な例と過剰設計の例:
// APPROPRIATE: Branded types prevent real mixing bugs
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
// APPROPRIATE: Discriminated union encodes real state machine
type AuthState =
| { status: 'logged_out' }
| { status: 'logging_in' }
| { status: 'logged_in'; user: User; token: string };
// OVER-ENGINEERED: Encoding HTTP method semantics in types
// Does this actually prevent bugs? Probably not.
type HttpMethodHasBody<M extends HttpMethod> =
M extends 'POST' | 'PUT' | 'PATCH' ? true : false;
type RequestWithBody<M extends HttpMethod> =
HttpMethodHasBody<M> extends true
? { method: M; body: unknown }
: { method: M; body?: never };
// OVER-ENGINEERED: Recursive type for deeply nested access
// Causes slow compilation and unreadable errors
type DeepGet<T, Path extends string> =
Path extends `${infer Head}.${infer Tail}`
? Head extends keyof T
? DeepGet<T[Head], Tail>
: never
: Path extends keyof T
? T[Path]
: never;
// Use lodash.get + unknown return type instead
判断のヒューリスティック:型を一文で説明できない場合は、コメントが必要です。二文を超える場合は、よりシンプルな方法(実行時検証やヘルパー関数)のほうが保守しやすくないか、再検討してください。
見分けるポイント:TypeScript の熟練エンジニアは、高度な型を使わないほうがよい場面を理解しています。目指すのは、バグを減らし、開発者体験を向上させることであり、型システムの習熟度を示すことではありません。同じようにバグを防げるのであれば、複雑な条件付き型よりシンプルなユニオン型を選びましょう。
品質チェックリスト
[ ] No bare `any` types — unknown or generics used instead
[ ] Primitive values that must not be mixed are branded (IDs, money, emails)
[ ] Sum types use discriminated unions, not boolean flags
[ ] External data parsed through Zod schemas (never typed as a known type without validation)
[ ] Switch statements over union types use exhaustive checking
[ ] Conditional types include a comment explaining what they compute
[ ] No type aliases that just rename primitives without branding
[ ] Generic constraints are as specific as needed, no more
[ ] satisfies used for config objects instead of widening assertions
[ ] Type-level tests (expect-type) for complex utility types
出力成果物
- ドメイン型モジュール — すべてのプリミティブなドメイン値(ID、金額、メールアドレス)のブランド型
- Zod スキーマ —
z.infer型をエクスポートするスキーマ定義 - 判別可能なユニオンの定義 — ステートマシン、API の結果、ドメインイベント
- 型安全なイベントエミッター — 型付きイベントマップを備えたジェネリックな EventEmitter
- ユーティリティ型 — プロジェクトで再利用できる条件付き型ユーティリティ