TypeScript上級講座 #7 実戦パターンとアンチパターン
六編を経て型を加工する道具をほぼ全て見ました。今回が最後の記事で、道具を離れて — 良い型と過剰な型を分ける感覚を整理します。anyとunknownの本当の違いから始めて、よく陥るアンチパターンと、それぞれに合った解決法までです。
any vs unknown vs never — 三つの危険信号
#
最も紛らわしい三つの型を一気に整理しましょう。
| 型 | 意味 | 安全か |
|---|---|---|
any | 「この値に対するチェックを全て切る」 | ✗ 危険 |
unknown | 「この値が何かわからない — 絞り込む前は触ることもできない」 | ✓ 安全 |
never | 「この値は存在し得ない」 | ✓ 意図的 |
any — すべての安全網が切れるところ
#
function dangerous(x: any) {
return x.foo.bar.baz(); // コンパイル通過 — ランタイムでは何でも起こる
}
dangerous(null); // 通過
dangerous(42); // 通過
dangerous('hello'); // 通過
anyが一箇所に入ると、そこからすべての自動補完と型チェックが消えます。またanyは他のすべての型に自由に流れ込めるので、一箇所のanyが他の箇所の安全網まで無力化することもあります。
anyを使うべきところはほぼありません。新しいコードでanyを見たらほぼ常にもっと良い答えがあります。
unknown — anyの安全な代替
#
unknownは「何が入っているかわからない」を表現しますが、絞り込む前にはほぼすべての動作が止まります。
function safe(x: unknown) {
// x.foo; ✗ unknownはプロパティアクセス不可
// x(); ✗ 呼び出し不可
// x + 1; ✗ 演算不可
if (typeof x === 'string') {
console.log(x.length); // OK — stringに絞られる
}
}fetch().then(r => r.json())の結果のように、外部から入ってくる値は常にunknownで受け取って絞り込むのが安全です。実践 #6で扱ったパターンがそれでした。
never — 意図的に空にする
#
neverは「ここには絶対に到達しない」または「このunionにメンバーが無い」を表現します。
// 1) exhaustivenessチェック — [#5]で見たパターン
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return ...;
case 'square': return ...;
default: {
const _: never = s; // メンバー漏れ時に✗
return 0;
}
}
}
// 2) 絶対に戻らない関数
function fail(message: string): never {
throw new Error(message);
}neverが登場すること自体は普通良いシグナルです。コンパイラが何かを正確に追跡しているという意味です。
as const — 最もよく使わずに損をする道具
#
#1 keyofとtypeofでas constの効果を見ました。再整理すれば — オブジェクトと配列を書いたままリテラル型として固定します。
const a = ['red', 'green', 'blue'];
// ^ string[]
const b = ['red', 'green', 'blue'] as const;
// ^ readonly ['red', 'green', 'blue']
type Color = (typeof b)[number];
// 'red' | 'green' | 'blue'
これがないと上で見たkeyof typeof OBJパターンがすべて崩れます。データを一箇所に定義して型を自動生成するほぼすべてのパターンがas constの上に立っています。
satisfies — 型チェック通過 + 狭い推論を両方とも
#
TypeScript 4.9で追加された道具。型互換性はチェックするが、変数の推論された狭い型はそのまま保存します。
type Routes = Record<string, string>;
// annotation — チェックはされるが狭い推論を失う
const a: Routes = {
home: '/',
about: '/about',
};
a.home; // string
a.unknown; // string — Record<string, string>なのでどんなキーでも通る
// satisfies — チェックもされ狭い推論も生き残る
const b = {
home: '/',
about: '/about',
} satisfies Routes;
b.home; // string
b.unknown; // ✗ 'unknown'というキーが無い
違いが要点です。: Routesと書くと変数の型がRoutesそのままになり、どんなキーでも通ってしまいます。satisfies Routesは「この形がRoutesと互換性があるかだけチェックし、変数の実際の型は書いたまま」を意味します。
どこに使うと良いか #
設定オブジェクト、ルートマップ、action定義のようなケース。「この形がどんなインターフェースを満たしながらも、具体的なキーと値は正確に知りたい」が出会う場面です。
type ActionMap = Record<string, (state: State) => State>;
const actions = {
increment: (s) => ({ ...s, count: s.count + 1 }),
reset: (s) => ({ ...s, count: 0 }),
} satisfies ActionMap;
// actions.incrementは正確に (s: State) => State
// actions.unknownは✗
satisfiesは最初に使うとぎこちないですが、慣れると型annotationをほぼ使わなくなる道具です。
アンチパターン — よく陥る罠 #
ここからアンチパターンを見ます。毎回正解があるわけではないですが、この形が見えたら一度立ち止まってもう一度考える価値があります。
1) すべての箇所に型を明示 #
TypeScriptは推論が強力です。すべての箇所に明示すると推論結果より狭い型が書かれて情報が消えることがよくあります。
const items: string[] = ['apple', 'banana'];
// 推論はstring[]だが、'apple' | 'banana'がより正確かも
const status: string = 'idle';
// 'idle'のような狭い推論を失う
関数の仮引数や戻り値の型のように外部契約に該当する箇所だけ明示し、変数宣言/コールバック/推論可能な箇所は推論に任せるのが普通もっと良いです。
2) asキャストの乱発
#
as Typeはコンパイラを欺く道具です。コンパイルは通過しますが、ランタイムにはチェックがありません。
const data = (await res.json()) as User;
// サーバーがUser形を送ったか保証なし
const id = parseInt(s) as UserId;
// 実はnumber | NaNかもしれないのに通過
ほとんどのasは次のうち一つに変えられます。
- 値を検証して絞る型ガード (
value is X) - assertion関数 (
asserts value is X) - zodのようなスキーマで検証
- branded types + ガードで出処を強制
DOM操作でe.target as HTMLInputElementのような箇所はやむを得ませんが、データの流れでasが見えたらもう一度考えてみてください。
3) 巨大なconditional typeチェーン #
型レベルのifが二、三個積み重なると読み修正が難しくなります。
type Foo<T> = T extends string
? T extends `${infer A}-${infer B}`
? A extends 'admin'
? B extends `${number}`
? AdminId<B>
: never
: never
: never
: never;このような形が出てきたら普通二つのうち一つです。
- 本当に型レベルで表現すべきこと(ライブラリ作成時に限定)
- 実はランタイム検証でもっとうまく解けること
ほとんどのアプリコードは2番です。高い型トリックで解くより、データ入口でzodのようなツールで検証して、その後は普通の型を使う方がほぼ常に読みやすいです。
4) Function型
#
Function自体を型として使うコードを時々見ます。これがほぼ常に落とし穴です。
function call(fn: Function) {
return fn(1, 2, 3); // どんな引数でも通る — 安全でない
}代わりに呼び出しシグネチャを明示してください。
function call(fn: (...args: unknown[]) => unknown) {
return fn(1, 2, 3);
}
// またはより狭く
function call<T>(fn: (a: number, b: number) => T): T {
return fn(1, 2);
}TypeScript ESLintのデフォルトルール@typescript-eslint/ban-typesがFunctionの使用を防ぐ理由がこれです。
5) Object / {}型
#
Objectと{}はほぼすべての値を受け取ります(null/undefined以外)。
function f(x: {}) {
// xはほぼすべての値 — null/undefinedだけ除外
// 事実上安全網が無い箇所
}
f(42);
f('hello');
f({ id: 1 });
f(true);「オブジェクトを受け取る」が意図だったなら、Record<string, unknown>やもっと具体的な形を使うのが安全です。
6) インデックスシグネチャの乱発 #
{ [key: string]: any }のような形を簡単に書きます。こうすると#1で見たkeyofが無力化されます。
type Bag = { [key: string]: any };
const bag: Bag = { name: 'curtis' };
bag.unknown; // any — どんなキーでも通る
可能なら明示的な形で、動的キーが本当に必要ならRecord<'a' | 'b' | 'c', V>のようにキーunionを絞ってください。
7) any[]よりunknown[]
#
配列の要素を知らないときany[]の代わりにunknown[]を使うと安全網が生き残ります。
function processAll(items: unknown[]) {
for (const x of items) {
if (typeof x === 'string') {
console.log(x.toUpperCase());
}
}
}配列自体は扱えますが、要素を使うには絞る必要があります。これが正常な安全モードです。
良い型の基準三つ #
最後に、「この型は良いか」を判断するときに見る三つ。
1) 意図が見えるか — stringよりStatus = 'idle' | 'loading' | 'done'の方が多くの意図を伝えます。Emailbrand型が単なるstringより正確です。
2) 自動補完が良いか — エディタで.を打ったとき意味のある候補が出るのが良い型のシグナル。anyが入ると自動補完が貧弱になります。
3) リファクタリングが安全か — フィールド名を変えたりシグネチャを変えるとき、影響を受ける箇所が一度に赤線で表示されるべきです。
三つを満たす型は自分でドキュメントになります。別途のコメント/ドキュメントへの依存度が減り、コードの中に情報が集まります。
どこまでやるか — 型のコスト #
最後にtrade-offを一行。TypeScriptトリックはタダではありません。
- コンパイラ時間が長くなる
- 同僚が読みにくくなる
- 次に自分自身が修正しにくくなる
ライブラリ作者でなければ、アプリコードの90%は普通の型で十分良く動作します。 このシリーズで見た道具は必要なところでだけ取り出して使う道具です。すべての箇所にconditional typeとmapped typeを入れる必要は全くありません。
良い基準 — 「この型がコストの分だけ価値を返してくれるか?」 その答えがyesのときだけ取り出して使ってください。
シリーズを終えて #
上級シリーズ7編を経て整理した道具:
- keyofとtypeof — 型を引き出す (#1)
- Mapped types — オブジェクト型を丸ごと変換 (#2)
- Conditional typesとinfer — 分岐と抽出 (#3)
- Template literal types — 文字列型合成 (#4)
- Discriminated unionと型ガード — 安全なモデリング (#5)
- モジュールと.d.ts — 外部型の扱い (#6)
- 実戦パターンとアンチパターン — 良い感覚 (今回)
このシリーズで扱ったのはほぼすべてすでに馴染みのある型の上に道具を積み上げることでした。道具自体より重要なのはいつどんな道具を取り出すか判断する感覚です。初めて出会う問題の前で「あ、これmapped typeで解けそうだ」または「これは普通のtype aliasで十分だ」という判断が素早く出るなら、シリーズの目的は達成されたことになります。
TypeScriptが私たちの仕事を妨げるのではなく一緒に進む仲間になる地点、それが結局私たちが目指していた地点です。