TypeScriptを書いていて、「なぜここはこの型に推論されるのか」「明示しないと壊れるのはなぜか」がモヤッとしたまま手癖でコードを書いている人は多いはずです。型推論はTypeScriptの最大の生産性ブースターであり、同時に最大のハマりポイントでもあります。本記事ではTypeScript 5.x準拠で、変数・関数・ジェネリクス・as const・satisfies・infer・NoInferまで、推論の仕組みを40以上のコピペで動くコード例と共に解体します。
対象は20〜40代の現役Webエンジニアで、TypeScript型の基礎とジェネリクス完全ガイドを読了し、「推論が思い通りにならないときに自分で原因特定できるレベル」を目指す層です。読了後はas constとsatisfiesの使い分け、NoInfer(TS 5.4+)の活用、Conditional Type内のinferパターンを「型がhoverで何になるか先に脳内で当てられる」解像度まで到達できます。
- 型推論とは何か:TypeScriptが代わりに書いてくれる型注釈
- 変数宣言の推論:let と const の決定的な違い
- 配列リテラルの推論:union と tuple の境界
- オブジェクトリテラルの推論:Excess Property と Fresh Object
- as const と satisfies の正しい使い分け
- 関数の戻り値推論と contextual typing
- ジェネリクスの型引数推論
- Conditional Type と infer による推論
- 配列メソッドの推論:map / filter / reduce / fromEntries
- narrowing:推論を分岐で絞り込む
- const enum / enum と型推論
- 共変・反変(Variance)と関数代入
- キャスト(as)と型アサーションの正しい使い方
- 推論結果を確認する実践テクニック
- パフォーマンス影響:推論コストと型コンプレキシティ
- Before / After:推論を活かしたリファクタリング集
- よくある推論失敗とトラブルシュート
- キャリアアップ:TypeScript の型推論を武器にする
- まとめ:推論を味方につけるための7原則
型推論とは何か:TypeScriptが代わりに書いてくれる型注釈
型推論(Type Inference)とは、明示的な型注釈を書かなくてもTypeScriptが文脈から型を自動的に決定する仕組みです。const x = 1; と書けばxは1型(リテラル型)、let x = 1; と書けばxはnumber型になります。この一行の挙動の差に、推論アルゴリズムの本質が詰まっています。
推論が効く3つの場面
// (1) 変数宣言での初期値推論
const a = 42; // a: 42 (リテラル型)
let b = 42; // b: number (widening)
// (2) 関数の戻り値推論
function double(n: number) {
return n * 2; // 戻り値: number と推論
}
type R = ReturnType<typeof double>; // number
// (3) ジェネリクスの型引数推論
function first<T>(arr: T[]): T {
return arr[0];
}
const v = first([1, 2, 3]); // T = number と推論
推論の威力:DRYと型安全の両立
「型注釈は冗長に書くべき」と教わった人ほど、推論を信頼しないコードを書きがちです。しかし推論できる場所では推論に任せた方が、型と実装の整合性が崩れにくいのがTypeScriptの設計思想です。
// ❌ 冗長な型注釈(値と二重管理)
const user: { id: number; name: string } = { id: 1, name: "alice" };
// ✅ 推論に任せる(値の変更が型に追従)
const user = { id: 1, name: "alice" };
// ^? const user: { id: number; name: string }
// ❌ 戻り値型を二重に書く
function getUser(): { id: number; name: string } {
return { id: 1, name: "alice" };
}
// ✅ 戻り値は推論に任せて、引数だけ明示
function getUser() {
return { id: 1, name: "alice" };
}
type User = ReturnType<typeof getUser>; // 単一の真実の源
本記事で扱う推論パターン全体像
| カテゴリ | 主なトピック | 難易度 |
|---|---|---|
| 変数 | let/const・widening・narrowing | ★☆☆ |
| リテラル | 配列・オブジェクト・as const・satisfies | ★★☆ |
| 関数 | 戻り値推論・contextual typing | ★★☆ |
| ジェネリクス | 型引数推論・推論失敗・NoInfer | ★★★ |
| Conditional | infer・Awaited・分配 | ★★★ |
| 配列メソッド | map/filter/reduce/fromEntries | ★★☆ |
| 落とし穴 | Excess Property・Fresh Object・Variance | ★★★ |
変数宣言の推論:let と const の決定的な違い
最も基本的でかつ最も見落とされがちなのが、letとconstでの推論結果の違いです。constは再代入できないため、初期値のリテラル型がそのまま型になります(narrow)。letは再代入可能なため、より広い型(widening)に拡張されます。
プリミティブ値での推論差
// const = リテラル型(narrow)
const n1 = 42; // 42
const s1 = "hello"; // "hello"
const b1 = true; // true
// let = プリミティブ型(widening)
let n2 = 42; // number
let s2 = "hello"; // string
let b2 = true; // boolean
// 関数引数として渡したときの差
function takeLiteral(x: 42) {}
takeLiteral(n1); // ✅ OK
takeLiteral(n2); // ❌ Argument of type 'number' is not assignable to '42'
オブジェクトプロパティは const でも widening される
意外な落とし穴が、オブジェクトのプロパティはconstでもwideningされることです。プロパティは内部的に書き換え可能だからです。
// const でもプロパティは widening される
const obj = { kind: "circle", radius: 5 };
// ^? const obj: { kind: string; radius: number }
// ^^^^^^ "circle" にならない!
// これだと困るケース
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(s: Shape) { /* ... */ }
area(obj); // ❌ string は "circle" | "square" に代入できない
// 解決策1: as const
const obj1 = { kind: "circle", radius: 5 } as const;
// ^? const obj1: { readonly kind: "circle"; readonly radius: 5 }
// 解決策2: satisfies
const obj2 = { kind: "circle", radius: 5 } satisfies Shape;
// ^? const obj2: { kind: "circle"; radius: number }
// 解決策3: 型注釈
const obj3: Shape = { kind: "circle", radius: 5 };
null / undefined の推論
// strictNullChecks=true 前提
// let で null/undefined 初期化は any (strict外) または該当型
let x1 = null; // null (strict)
let x2 = undefined; // undefined (strict)
// 後から再代入したい場合は型注釈が必須
let user: User | null = null;
user = { id: 1, name: "alice" }; // ✅
// const は当然そのまま
const z1 = null; // null
const z2 = undefined; // undefined
配列リテラルの推論:union と tuple の境界
配列リテラルは「要素型のunion で構成された配列型」として推論されます。タプル型にしたい場合はas constまたは明示的なタプル型注釈が必要です。
基本: union 配列としての推論
// 同じ型なら素直
const nums = [1, 2, 3]; // number[]
const strs = ["a", "b", "c"]; // string[]
// 異なる型が混ざると union 配列
const mixed = [1, "a", true]; // (string | number | boolean)[]
// tuple ではない点に注意
const pair = [1, "alice"]; // (string | number)[]
// ^? NOT [number, string]
const [id, name] = pair;
// ^? number | string ← name に id の型まで混入
as const でタプル化
// as const で readonly tuple に固定
const pair = [1, "alice"] as const;
// ^? readonly [1, "alice"]
const [id, name] = pair;
// ^? id: 1, name: "alice"
// ReactのuseState風の戻り値もこの仕組み
function useToggle(init: boolean) {
let v = init;
const toggle = () => { v = !v; };
return [v, toggle] as const;
// ^? readonly [boolean, () => void]
}
const [value, toggle] = useToggle(false);
// ^? boolean, () => void ← 分割代入で型が崩れない
タプルを明示的に型注釈する
// 明示型注釈でタプル化
const pair: [number, string] = [1, "alice"];
// 戻り値型でタプル指定
function divmod(a: number, b: number): [number, number] {
return [Math.floor(a / b), a % b];
}
const [q, r] = divmod(10, 3); // q: number, r: number
// 可変長タプル(TS 4.0+)
type Range = [start: number, end: number, ...steps: number[]];
const r: Range = [0, 10, 1, 2, 5];
オブジェクトリテラルの推論:Excess Property と Fresh Object
オブジェクトリテラルにはExcess Property Check(余剰プロパティチェック)という特殊なルールがあり、これは「リテラルそのものを直接代入したときだけ厳しくなる」一見不思議な挙動を生みます。理解せずに使うと「同じオブジェクトなのに変数経由ならOKでリテラルならエラー」というハマりに遭遇します。
余剰プロパティチェックの基本
type User = { id: number; name: string };
// ❌ オブジェクトリテラル直接代入は厳しくチェックされる
const u1: User = { id: 1, name: "alice", age: 30 };
// ^^^ Object literal may only specify known properties
// ✅ 一度変数経由ならOK(Fresh ではないので緩い)
const tmp = { id: 1, name: "alice", age: 30 };
const u2: User = tmp; // OK(age は無視されるだけ)
// この挙動の理由:タイポ検出を優先したい
const u3: User = { id: 1, naem: "alice" };
// ^^^^ "name" のタイポ検出
Fresh Object Literal の判定
// "Fresh" とは「リテラルとして生まれてから一度も別の場所に代入されていない」状態
function takeUser(u: User) {}
// ✅ Fresh だが、関数呼び出しの引数は Excess Check 対象
takeUser({ id: 1, name: "alice", age: 30 });
// ^^^ Excess Property エラー
// ✅ 一度束縛したオブジェクトは Fresh ではなくなる
const obj = { id: 1, name: "alice", age: 30 };
takeUser(obj); // OK
// ✅ as キャストで Fresh を剥がす(非推奨だが知っておく)
takeUser({ id: 1, name: "alice", age: 30 } as User); // OK だが危険
オプショナルと余剰の組み合わせ
type Config = {
host: string;
port?: number;
// index signature がない
};
// ❌ オプショナルにない未知プロパティはNG
const c1: Config = { host: "x", potr: 80 };
// ^^^^ "port" のタイポ警告
// ✅ index signature を足すと許容になる
type ConfigLoose = {
host: string;
port?: number;
[key: string]: unknown;
};
const c2: ConfigLoose = { host: "x", debug: true }; // OK
as const と satisfies の正しい使い分け
TS 4.9で追加されたsatisfiesは、as constと「型注釈」の中間の役割を担います。3者の違いを理解せずに混在させると、不要なreadonlyや型情報の喪失を招きます。
3パターンの比較
type Color = "red" | "green" | "blue";
type Palette = Record<string, Color>;
// (1) 型注釈:右辺の型が左辺の型に上書きされる
const p1: Palette = {
primary: "red",
accent: "blue",
};
p1.primary; // Color (具体名が消える)
// (2) as const:値そのものの最narrow型に固定
const p2 = {
primary: "red",
accent: "blue",
} as const;
p2.primary; // "red" ← 具体値が残る
// ただし Palette を満たすかはチェックされない
// (3) satisfies:型をチェックしつつ、推論結果を保持
const p3 = {
primary: "red",
accent: "blue",
} satisfies Palette;
p3.primary; // "red" ← 具体値が残る かつ Palette 違反は型エラー
satisfies が真価を発揮するケース
type RouteConfig = Record<string, { path: string; auth?: boolean }>;
// ❌ 型注釈だと具体的なキー名が string に潰れる
const routesA: RouteConfig = {
home: { path: "/" },
admin: { path: "/admin", auth: true },
};
type KeysA = keyof typeof routesA; // string ← 個別キーが取れない
// ✅ satisfies なら具体的キー名が保持される
const routesB = {
home: { path: "/" },
admin: { path: "/admin", auth: true },
} satisfies RouteConfig;
type KeysB = keyof typeof routesB; // "home" | "admin"
const k: KeysB = "home"; // OK
as const と satisfies の組み合わせ
// 両方付けることで「readonly + 制約チェック + narrow型保持」を実現
type EventMap = Record<string, { payload: unknown }>;
const events = {
"user.created": { payload: { id: 0, name: "" } },
"user.deleted": { payload: { id: 0 } },
} as const satisfies EventMap;
type EventName = keyof typeof events;
// "user.created" | "user.deleted"
type CreatedPayload = typeof events["user.created"]["payload"];
// { readonly id: 0; readonly name: "" }
// 具体的構造まで型として保持される
関数の戻り値推論と contextual typing
関数の引数は明示するが戻り値は推論に任せる、というのがTypeScriptの一般的な書き方です。さらに関数式やコールバックでは、外側の文脈から引数型が推論されるcontextual typingが働きます。
戻り値推論の基本
// 戻り値は条件分岐をすべて辿って union として推論される
function classify(n: number) {
if (n < 0) return "negative" as const;
if (n === 0) return "zero" as const;
return "positive" as const;
}
type C = ReturnType<typeof classify>;
// "negative" | "zero" | "positive"
// 早期リターンと throw は型から除外される
function getOrThrow(map: Map<string, number>, key: string) {
const v = map.get(key);
if (v === undefined) throw new Error("not found");
return v; // number(undefined は除外済み)
}
contextual typing で引数型を省略
// 配列メソッドのコールバックは contextual typing が効く
const nums = [1, 2, 3];
nums.forEach((n) => {
// ^? n: number ← 引数型を書かなくても推論
console.log(n.toFixed(2));
});
nums.map((n, i) => n * i);
// n: number, i: number, 戻り値: number → map の戻り値: number[]
// addEventListener のイベント型も contextual typing
document.addEventListener("click", (e) => {
// ^? e: MouseEvent
console.log(e.clientX);
});
// Promise.then も同様
fetch("/api").then((res) => {
// ^? res: Response
return res.json();
});
contextual typing が効かないパターン
// ❌ 別変数に切り出すと contextual typing が消える
const handler = (e) => {
// ^^^ Parameter 'e' implicitly has an 'any' type
console.log(e.clientX);
};
document.addEventListener("click", handler);
// ✅ 解決策1: 型注釈
const handler1 = (e: MouseEvent) => {
console.log(e.clientX);
};
// ✅ 解決策2: 型エイリアスで関数型を定義
type Handler = (e: MouseEvent) => void;
const handler2: Handler = (e) => {
console.log(e.clientX); // OK
};
// ✅ 解決策3: インラインで書く
document.addEventListener("click", (e) => {
console.log(e.clientX); // OK
});
ジェネリクスの型引数推論
ジェネリクス関数は引数の型から自動的に型引数を決定します。これが効かないとき、または意図しない型に推論されたときの対処を理解することが、ライブラリ実装で必要になります。
基本的な型引数推論
function identity<T>(x: T): T {
return x;
}
const a = identity(42); // T = number → a: number
const b = identity("hi"); // T = string → b: string
const c = identity<42>(42); // 明示も可能 → c: 42
// 配列要素の推論
function head<T>(arr: T[]): T | undefined {
return arr[0];
}
const h1 = head([1, 2, 3]); // T = number → h1: number | undefined
const h2 = head(["a", "b"]); // T = string → h2: string | undefined
// 複数引数からの推論
function pair<A, B>(a: A, b: B): [A, B] {
return [a, b];
}
const p = pair(1, "x"); // A = number, B = string → [number, string]
推論が失敗するケースと明示
// 戻り値だけに登場する型引数は推論されない
function create<T>(): T {
return {} as T;
}
const x = create(); // T が決まらない → unknown(または契約上の型)
const y = create<User>(); // 明示が必要
// オーバーロード解決と推論
function parse<T>(input: string): T;
function parse<T>(input: string, fallback: T): T;
function parse<T>(input: string, fallback?: T): T {
try { return JSON.parse(input); } catch { return fallback as T; }
}
const v1 = parse("{}"); // T 不明 → 明示推奨
const v2 = parse<User>("{...}"); // T = User
const v3 = parse("{}", { id: 0 }); // fallback から T = { id: number }
extends constraint で推論を誘導する
// keyof で推論を絞る
function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "alice", age: 30 };
const id = pluck(user, "id"); // K = "id" → id: number
const name = pluck(user, "name"); // K = "name" → name: string
// const x = pluck(user, "xxx"); // ❌ "xxx" は keyof user に含まれない
// 配列要素の型を返す
function last<T extends readonly unknown[]>(arr: T): T[number] {
return arr[arr.length - 1];
}
const e = last([1, "a", true] as const);
// ^? 1 | "a" | true
NoInfer による推論抑制 (TS 5.4+)
TS 5.4で追加されたNoInfer<T>は、「この引数からは型推論しないでほしい」と指示するユーティリティ型です。複数引数からの推論競合を避けたいときに使います。
// ❌ 第2引数からも T が推論されて緩くなる
function createState<T>(initial: T, fallback: T): T {
return Math.random() > 0.5 ? initial : fallback;
}
const s1 = createState("a", "b"); // T = "a" | "b"(union 化)
const s2 = createState(1 as const, 2); // T = 1 | 2
// ✅ NoInfer で第1引数のみから推論
function createStateV2<T>(initial: T, fallback: NoInfer<T>): T {
return Math.random() > 0.5 ? initial : fallback;
}
const s3 = createStateV2("a", "b");
// T = "a"(initial だけから推論)
// "b" は "a" に代入できないので型エラー → 意図しない union を防げる
// 設定オブジェクトのバリデーションでも有効
type Config<T extends string> = {
options: T[];
default: NoInfer<T>;
};
const cfg: Config<"a" | "b"> = {
options: ["a", "b"],
default: "a", // OK
// default: "c", // ❌ "a" | "b" のみ
};
Conditional Type と infer による推論
inferキーワードは、Conditional Type内で「型の一部を変数として取り出す」仕組みです。ReturnTypeやAwaitedなど、組み込みユーティリティ型の中核を成します。
infer の基本構文
// 関数型から戻り値を取り出す
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() { return { id: 1, name: "alice" }; }
type U = MyReturnType<typeof getUser>;
// { id: number; name: string }
// 配列の要素型を取り出す
type ElementOf<T> = T extends (infer E)[] ? E : never;
type E1 = ElementOf<number[]>; // number
type E2 = ElementOf<("a" | "b")[]>; // "a" | "b"
// Promise の中身を取り出す(Awaited の簡易版)
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type P1 = UnwrapPromise<Promise<number>>; // number
type P2 = UnwrapPromise<string>; // string(Promiseでなければそのまま)
Awaited:再帰的 Promise 解決
// 標準の Awaited は Promise の入れ子も再帰的に剥がす
type A1 = Awaited<Promise<number>>; // number
type A2 = Awaited<Promise<Promise<string>>>; // string(再帰解決)
type A3 = Awaited<Promise<Promise<Promise<User>>>>; // User
// async 関数の戻り値推論で活躍
async function fetchUser(id: number) {
const res = await fetch(`/users/${id}`);
return res.json() as Promise<User>;
}
type FetchUserResult = Awaited<ReturnType<typeof fetchUser>>;
// User(Promise が剥がれる)
// 自前で Awaited 風に書く場合(参考実装)
type MyAwaited<T> = T extends Promise<infer U> ? MyAwaited<U> : T;
tuple から head / tail / last を取り出す
type Head<T extends readonly unknown[]> = T extends [infer H, ...unknown[]] ? H : never;
type Tail<T extends readonly unknown[]> = T extends [unknown, ...infer R] ? R : [];
type Last<T extends readonly unknown[]> = T extends [...unknown[], infer L] ? L : never;
type T1 = Head<[1, 2, 3]>; // 1
type T2 = Tail<[1, 2, 3]>; // [2, 3]
type T3 = Last<[1, 2, 3]>; // 3
// 関数引数列の操作
type Parameters2<F> = F extends (...args: infer A) => unknown ? A : never;
type DropFirst<F> = F extends (a: any, ...rest: infer R) => unknown ? R : never;
function send(channel: string, ...payload: number[]) {}
type SendArgs = Parameters2<typeof send>; // [string, ...number[]]
type SendRest = DropFirst<typeof send>; // number[]
分配条件型(distributive)と infer
// union に対して Conditional Type は分配される
type ToArray<T> = T extends unknown ? T[] : never;
type R1 = ToArray<string | number>;
// string[] | number[] ← 分配される
// 分配を抑制したいときは [] で囲む
type ToArrayNoDist<T> = [T] extends [unknown] ? T[] : never;
type R2 = ToArrayNoDist<string | number>;
// (string | number)[]
// 実用例: union から特定の型だけ取り出す
type ExtractFunc<T> = T extends (...args: any[]) => any ? T : never;
type Mix = string | (() => void) | number | ((x: number) => string);
type Funcs = ExtractFunc<Mix>;
// (() => void) | ((x: number) => string)
配列メソッドの推論:map / filter / reduce / fromEntries
日常的に使う配列メソッドは、「推論が綺麗に効くケース」と「明示しないと型が崩れるケース」がはっきり分かれます。特にfilterとreduceは要注意です。
map の推論
const nums = [1, 2, 3];
// シンプルな変換は綺麗に推論される
const doubled = nums.map((n) => n * 2);
// ^? number[]
const strs = nums.map((n) => n.toString());
// ^? string[]
// オブジェクトへの変換
const items = nums.map((n) => ({ id: n, label: `Item ${n}` }));
// ^? { id: number; label: string }[]
// インデックス活用
const enumerated = nums.map((n, i) => [i, n] as const);
// ^? (readonly [number, number])[]
filter による絞り込みと型ガード関数
// ❌ 通常の filter は型を絞らない
const xs: (number | null)[] = [1, null, 2, null, 3];
const onlyNums = xs.filter((x) => x !== null);
// ^? (number | null)[] ← null が残ったまま!
// ✅ 型述語(type predicate)で絞る
const onlyNumsV2 = xs.filter((x): x is number => x !== null);
// ^? number[]
// ✅ TS 5.5+ では `filter` の戻り値型を自動推論する強化が入る
const onlyNumsV3 = xs.filter((x) => x !== null);
// TS 5.5+: number[]
// 汎用 NonNullable ガード
function isNonNull<T>(x: T): x is NonNullable<T> {
return x !== null && x !== undefined;
}
const onlyNumsV4 = xs.filter(isNonNull);
// ^? number[]
reduce の推論失敗パターン
// ❌ 初期値が空配列だと推論失敗
const items = [
{ tag: "a", value: 1 },
{ tag: "b", value: 2 },
{ tag: "a", value: 3 },
];
const grouped1 = items.reduce((acc, item) => {
acc[item.tag] = acc[item.tag] || [];
// ^^^^^^^^ never に index アクセスはできない
acc[item.tag].push(item);
return acc;
}, {});
// ✅ 初期値に型注釈
const grouped2 = items.reduce<Record<string, typeof items>>((acc, item) => {
acc[item.tag] = acc[item.tag] || [];
acc[item.tag].push(item);
return acc;
}, {});
// ✅ もしくは初期値を明示型
const init: Record<string, typeof items> = {};
const grouped3 = items.reduce((acc, item) => {
acc[item.tag] = acc[item.tag] || [];
acc[item.tag].push(item);
return acc;
}, init);
Object.fromEntries の推論限界
// ❌ 標準の Object.fromEntries は string キーに潰れる
const entries = [["a", 1], ["b", 2]] as const;
const obj1 = Object.fromEntries(entries);
// ^? { [k: string]: 1 | 2 } ← 具体キー不明
// ✅ 自前で型安全 fromEntries を書く
function fromEntries<
const T extends readonly (readonly [PropertyKey, unknown])[]
>(entries: T): { [K in T[number] as K[0]]: K[1] } {
return Object.fromEntries(entries) as any;
}
const obj2 = fromEntries([["a", 1], ["b", 2]] as const);
// ^? { a: 1; b: 2 }
// 実用: 設定キーの一覧から型安全な辞書を生成
const KEYS = ["host", "port", "debug"] as const;
const config = fromEntries(KEYS.map((k) => [k, ""] as const));
// ^? { host: ""; port: ""; debug: "" }
narrowing:推論を分岐で絞り込む
narrowing(型の絞り込み)は「ある変数の型を、分岐内でより具体的な型に推論し直す」仕組みです。typeof / instanceof / in 演算子 / === / 型述語など、多数の手段があります。
typeof による narrowing
function format(v: string | number | boolean) {
if (typeof v === "string") {
return v.toUpperCase(); // v: string
}
if (typeof v === "number") {
return v.toFixed(2); // v: number
}
return v ? "Y" : "N"; // v: boolean(残り)
}
discriminated union と narrowing
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rect"; w: number; h: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2; // s.radius が見える
case "square": return s.side * s.side; // s.side が見える
case "rect": return s.w * s.h; // s.w, s.h が見える
}
}
// exhaustiveness check
function areaStrict(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2;
case "square": return s.side * s.side;
case "rect": return s.w * s.h;
default:
const _exhaustive: never = s; // 全分岐網羅されていなければ型エラー
return _exhaustive;
}
}
narrowing が消える落とし穴
// ❌ クロージャ内では narrowing が失われる
function buggy(input: string | null) {
if (input === null) return;
// ここでは input: string
setTimeout(() => {
input.toUpperCase(); // ❌ input は string | null に戻る
// ^^^^^^^^^ Object is possibly 'null'
}, 100);
}
// ✅ ローカル変数に束縛して narrowing を保持
function fixed(input: string | null) {
if (input === null) return;
const s = input; // s: string
setTimeout(() => {
s.toUpperCase(); // OK
}, 100);
}
const enum / enum と型推論
enumは推論の文脈で挙動が独特で、「union of literal types」と等価ではなく、専用の名義型(nominal type)として振る舞います。リテラル型とas constオブジェクトの方が推論との相性は良いケースが多いです。
enum / const enum / union 比較
// (1) 通常の enum:ランタイムオブジェクトが生成される
enum ColorA { Red = "red", Green = "green" }
function takeA(c: ColorA) {}
takeA("red"); // ❌ 文字列リテラルは enum と互換でない
takeA(ColorA.Red); // ✅
// (2) const enum:インライン化される(配布ライブラリでは非推奨)
const enum ColorB { Red = "red", Green = "green" }
function takeB(c: ColorB) {}
takeB(ColorB.Red); // ✅(コンパイル後は "red" にインライン)
// (3) as const オブジェクト + union(推奨)
const ColorC = { Red: "red", Green: "green" } as const;
type ColorC = typeof ColorC[keyof typeof ColorC]; // "red" | "green"
function takeC(c: ColorC) {}
takeC("red"); // ✅
takeC(ColorC.Red); // ✅
共変・反変(Variance)と関数代入
関数型の代入可能性には「引数は反変・戻り値は共変」というルールがあり、これが推論結果の互換性に直結します。特にコールバック引数が想定より広い型に推論される現象の元凶です。
共変・反変の基本
// 引数: 反変(広い方を受け取れる関数は、狭い方を要求する場所に代入可能)
type AnimalHandler = (a: { name: string }) => void;
type DogHandler = (d: { name: string; breed: string }) => void;
const animalH: AnimalHandler = (a) => console.log(a.name);
const dogH: DogHandler = animalH; // ✅ AnimalHandler は DogHandler に代入可
// (DogHandler の場所は Animal を受ければ十分なので OK)
// 戻り値: 共変(狭い方を返す関数は、広い方を要求する場所に代入可能)
type GetAnimal = () => { name: string };
type GetDog = () => { name: string; breed: string };
const getDog: GetDog = () => ({ name: "pochi", breed: "shiba" });
const getAnimal: GetAnimal = getDog; // ✅ Dog は Animal を満たす
// strictFunctionTypes=true で引数は厳密な反変チェック
配列メソッドのコールバック型と Variance
// Array.prototype.forEach の callback は (v, i, arr) => void
// 引数を一部だけ書いても OK なのは反変ゆえ
const xs = [1, 2, 3];
xs.forEach(() => {}); // ✅ 引数なし
xs.forEach((v) => {}); // ✅
xs.forEach((v, i) => {}); // ✅
xs.forEach((v, i, arr) => {}); // ✅
// 戻り値型の弛緩例
xs.forEach((v) => v.toString()); // ✅ 戻り値 void が要求でも string を返してOK
キャスト(as)と型アサーションの正しい使い方
キャスト(as)は「推論結果を強制的に上書きする」最終手段です。多用は型安全性を破壊しますが、適切に使えば外部入力との接続点で必要になります。
as の正しいユースケース
// (1) JSON のレスポンスを型付け(検証は別途すべき)
const res = await fetch("/api/user");
const user = await res.json() as User;
// (2) DOM 要素のキャスト
const el = document.getElementById("app") as HTMLDivElement;
// (3) 段階的な any → 具体型
const data = JSON.parse("{}") as unknown as Config;
// ❌ ダメな例:推論結果を雑に書き換える
const n = "42" as unknown as number; // 実行時は string のまま!
// ✅ ランタイム検証と組み合わせる
function asUser(x: unknown): User {
if (typeof x !== "object" || x === null) throw new Error();
if (typeof (x as any).id !== "number") throw new Error();
if (typeof (x as any).name !== "string") throw new Error();
return x as User;
}
satisfies で as を置き換える
type Theme = Record<string, { bg: string; fg: string }>;
// ❌ as Theme だと具体的なキーが消える
const themeA = {
light: { bg: "#fff", fg: "#000" },
dark: { bg: "#000", fg: "#fff" },
} as Theme;
themeA.light; // { bg: string; fg: string }
themeA.xxxx; // 型エラーにならない(string indexable)
// ✅ satisfies なら具体的構造と制約の両方を維持
const themeB = {
light: { bg: "#fff", fg: "#000" },
dark: { bg: "#000", fg: "#fff" },
} satisfies Theme;
themeB.light; // { bg: string; fg: string }
themeB.xxxx; // ❌ 型エラー
推論結果を確認する実践テクニック
推論結果を頭の中だけで考えるのは限界があります。エディタとTypeScript本体の機能を駆使して、推論結果を可視化するのがプロの作業です。
VSCode で型を確認する3つの方法
// (1) hover:変数や式にカーソルを当てる → ツールチップで表示
const x = [1, 2, 3].map((n) => n * 2);
// ^? hover で number[] が表示される
// (2) Twoslash 風コメント:VSCode 拡張 "TypeScript Twoslash" で有効化
const y = { id: 1, name: "alice" };
// ^?
// 上の ^? の行が、エディタ上でリアルタイムに型を表示してくれる
// (3) 型を変数に取り出して確認
type Inferred = typeof y;
// ^? { id: number; name: string }
tsc / tsserver で型を吐く
// tsc --noEmit でビルドせず型エラーだけ確認
// $ npx tsc --noEmit
// .d.ts として宣言ファイルだけ生成して中身を読む
// $ npx tsc --declaration --emitDeclarationOnly --outDir types
// 推論結果を露出させる定型パターン
function debug<T>(x: T): T { return x; }
const v = debug({ a: 1, b: "x" });
// ^? { a: number; b: string }
// expectType ヘルパ(@arktype/attest 等)
import { attest } from "@arktype/attest";
attest<typeof v>().equals<{ a: number; b: string }>();
パフォーマンス影響:推論コストと型コンプレキシティ
推論は無料ではありません。巨大な union や深い conditional type は、tsc のコンパイル時間を秒単位で押し上げることがあります。実プロジェクトでの対策を押さえます。
推論を重くするパターン
// ❌ 100要素超の as const は推論コスト増
const HUGE = [/* 1000要素のリテラル */] as const;
type HugeUnion = typeof HUGE[number];
// ❌ 深いネストの conditional type
type DeepMap<T> = T extends object
? { [K in keyof T]: DeepMap<T[K]> }
: T extends string ? `prefix_${T}` : T;
// ✅ 対策1: 型エイリアスでキャッシュ
type CachedHuge = HugeUnion;
// ✅ 対策2: 大きな型は import type で型のみ取り込む
import type { GiantType } from "./giant";
// ✅ 対策3: 推論を諦めて type 注釈
const items: Item[] = [...]; // satisfies は推論し続けるので注釈の方が軽い
計測ツール
// tsc 自体に計測フラグがある
// $ npx tsc --extendedDiagnostics
// → Files / Lines / Identifiers / Memory / Total time が出力
// 詳細なトレース
// $ npx tsc --generateTrace ./.trace
// → Chrome DevTools の Performance タブで読み込める
// 個別ファイルのチェック時間
// $ npx tsc --diagnostics
// 型のインスタンス化深度を制限
// tsconfig.json
// {
// "compilerOptions": {
// "noImplicitAny": true,
// "strict": true
// }
// }
Before / After:推論を活かしたリファクタリング集
ここまでの内容を、実プロジェクトで頻出する「型を二重管理してしまっている」コードのリファクタリング例で総ざらいします。
例1:設定オブジェクトの二重定義
// ❌ Before:型と値を二重管理
type Routes = {
home: string;
about: string;
contact: string;
};
const routes: Routes = {
home: "/",
about: "/about",
contact: "/contact",
};
type RouteKey = keyof Routes; // "home" | "about" | "contact"
// ✅ After:値から型を導出
const routes = {
home: "/",
about: "/about",
contact: "/contact",
} as const;
type RouteKey = keyof typeof routes; // "home" | "about" | "contact"
type RoutePath = typeof routes[RouteKey]; // "/" | "/about" | "/contact"
例2:API レスポンスの型
// ❌ Before:戻り値型と実装の二重宣言
interface UserResponse {
id: number;
name: string;
email: string;
}
async function fetchUser(id: number): Promise<UserResponse> {
const res = await fetch(`/users/${id}`);
return res.json();
}
// ✅ After:zod 等のスキーマから推論
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
async function fetchUser(id: number) {
const res = await fetch(`/users/${id}`);
return UserSchema.parse(await res.json()); // 戻り値型は User と推論
}
例3:Reducer の型
// ❌ Before:Action 型を手書きで全部列挙
type Action =
| { type: "INCREMENT" }
| { type: "DECREMENT" }
| { type: "RESET"; payload: number };
function reducer(state: number, action: Action): number {
switch (action.type) {
case "INCREMENT": return state + 1;
case "DECREMENT": return state - 1;
case "RESET": return action.payload;
}
}
// ✅ After:Action を実装から推論
const actions = {
increment: () => ({ type: "INCREMENT" as const }),
decrement: () => ({ type: "DECREMENT" as const }),
reset: (n: number) => ({ type: "RESET" as const, payload: n }),
};
type Action = ReturnType<typeof actions[keyof typeof actions]>;
// 自動的に上と同じ union が生成される
例4:状態マシンの型安全化
// ❌ Before:状態名が string で型安全でない
const machine = {
initial: "idle",
states: { idle: {}, loading: {}, success: {}, error: {} },
};
function go(state: string) {
return machine.states[state]; // any
}
// ✅ After:satisfies + as const で型安全に
const machine = {
initial: "idle",
states: {
idle: { on: { FETCH: "loading" } },
loading: { on: { SUCCESS: "success", ERROR: "error" } },
success: {},
error: { on: { RETRY: "loading" } },
},
} as const satisfies {
initial: string;
states: Record<string, { on?: Record<string, string> }>;
};
type State = keyof typeof machine.states; // "idle" | "loading" | ...
function go(s: State) {
return machine.states[s]; // 具体的構造が見える
}
よくある推論失敗とトラブルシュート
Q1: any にされてしまう
// 原因例: callback の引数が contextual typing 文脈外
const handler = (e) => e.clientX; // e: any
// 対策: 関数型注釈
type Handler = (e: MouseEvent) => number;
const handler: Handler = (e) => e.clientX;
// または直接引数注釈
const handler = (e: MouseEvent) => e.clientX;
// tsconfig で strict / noImplicitAny を有効化して気づきやすくする
Q2: union に意図せず潰される
// 原因例: 戻り値が条件分岐ごとに違う型
function getItem(kind: "user" | "post") {
if (kind === "user") return { id: 1, name: "alice" };
return { id: 1, title: "post" };
}
type R = ReturnType<typeof getItem>;
// { id: number; name: string } | { id: number; title: string }
// 必要に応じて discriminator を入れる
function getItemTyped(kind: "user" | "post") {
if (kind === "user") return { kind: "user" as const, id: 1, name: "alice" };
return { kind: "post" as const, id: 1, title: "post" };
}
Q3: 推論結果が想定より広い
// 原因例: const なのに widening される(プリミティブ以外)
const config = { mode: "dev", port: 3000 };
// config.mode: string ← "dev" にならない
// 対策パターン
const config1 = { mode: "dev", port: 3000 } as const; // 全部 readonly
const config2 = { mode: "dev" as const, port: 3000 }; // 個別に固定
const config3 = { mode: "dev", port: 3000 } satisfies Config;
Q4: filter で型が絞れない
// 原因: 通常の filter は型述語を使わないと型を絞らない
const xs: (string | undefined)[] = ["a", undefined, "b"];
// ❌
const ys = xs.filter((x) => x !== undefined); // (string | undefined)[]
// ✅ 型述語
const zs = xs.filter((x): x is string => x !== undefined); // string[]
// ✅ 汎用 NonNullable ガード
function isDefined<T>(x: T): x is NonNullable<T> { return x != null; }
const ws = xs.filter(isDefined); // string[]
Q5: ジェネリクスの型引数が unknown になる
// 原因: 引数から T が現れない関数
function create<T>(): T { return {} as T; }
const x = create(); // T 不明 → unknown
// 対策1: 呼び出し側で明示
const u = create<User>();
// 対策2: ファクトリ引数を入れて推論可能に
function createFromShape<T>(shape: T): T { return shape; }
const v = createFromShape({ id: 0, name: "" }); // T 推論成功
// 対策3: builder パターンで段階的に固める
class Builder<T = {}> {
data: T;
constructor(data: T) { this.data = data; }
set<K extends string, V>(key: K, value: V) {
return new Builder({ ...this.data, [key]: value } as T & Record<K, V>);
}
}
const b = new Builder({}).set("id", 1).set("name", "alice");
// b.data: { id: number; name: string }
キャリアアップ:TypeScript の型推論を武器にする
ここまで読み切ったあなたは、すでにチーム内で「型に詳しい人」のポジションです。型推論を使いこなせるエンジニアは、ライブラリ実装・型安全なAPIクライアント設計・大規模リファクタリングで重宝されます。年収レンジで言えば、フロントエンドの実務経験+TypeScript深度がある人材は700〜1,000万円帯がボリュームゾーンになっています。
体系学習で抜け漏れを潰す
独学だと「inferを雰囲気で使っている」「satisfiesの使い分けが感覚」のままになりがちです。テックアカデミーのフロントエンドコースは現役エンジニアによるマンツーマンメンタリングがあり、ESLint設定・型設計・PR レビュー観点を含めた業務直結のフィードバックが受けられます。型推論のような「自分のコードを見せて初めて指摘される」領域とは特に相性が良いです。
転職で「TypeScript型に強い」を武器にする
React + TypeScript の求人は売り手市場が継続しており、特にライブラリ実装経験・OSSコントリビュート・型パズル(type challenges)を解いた経験のあるエンジニアは選考通過率が高い傾向です。侍エンジニアでは転職支援込みで現役エンジニア講師がマンツーマン対応してくれるため、ポートフォリオのコードレビュー段階から型設計を磨けます。
未経験から本気でモダンフロントを習得するなら
「そもそもReact/TypeScriptを業務レベルで身につけたい」段階の方には、DMM WEBCAMPのようなオンライン完結のコースが選択肢です。型推論を理解するためには JavaScript / モジュールシステム / 非同期処理の基礎が前提になるため、土台から固める方が結局速いです。
すでに現役なら、より高単価の現場へ
SES・自社開発・スタートアップなど現役エンジニアの方は、フリーランス案件で単価帯を引き上げるのも合理的です。レバテックフリーランスは React + TypeScript の案件保有数が多く、月単価80〜120万円帯の案件を担当営業経由で紹介してもらえます。「型推論まで解像度がある」エンジニアはコードレビュー要員としても期待されるため、面談で本記事のsatisfiesとNoInferの使い分けを語れるだけで差別化になります。
まとめ:推論を味方につけるための7原則
- 推論できる場所では型注釈を書かない:値と型の二重管理を避ける
- const と let の違いを意識する:リテラル型保持か widening かが分かれる
- as const と satisfies を使い分ける:readonly が欲しいか、制約だけ欲しいか
- contextual typing を活かす:コールバックは関数型を期待する文脈で書く
- narrowing は分岐の外で消える:必要ならローカル束縛で保持
- filter には型述語を付ける:NonNullable で型を絞る習慣化
- 困ったら hover と typeof で確認:推論結果の可視化を怠らない
型推論の解像度は、TypeScriptを書く速度と安全性の両方を引き上げる「投資対効果の最も高いスキル」です。一通り読み終わったら、ぜひ手元の業務コードでtypeofとsatisfiesを試し、推論結果を hover で確認しながら、型と実装の二重管理を1つずつ潰してみてください。
関連記事:TypeScript型の基礎完全ガイド / TypeScriptジェネリクス完全ガイド / TypeScript Utility Types完全リファレンス / TypeScript完全実践ガイド

コメント