表現の改善にAIを活用しています。
あらかじめご了承ください。
T extends unknown ? ... : never
一見「必ずtrueになる無意味な条件」に見えますが、ユニオン型を1メンバーずつ分解して処理させる(分配させる) ための定番イディオムです。
T extends unknown
unknown はトップ型で、どんな型も unknown に代入可能です。そのため条件は必ず成立し、: の分岐には実質的に到達しません。never は「書かないといけないから置いている」だけです。
つまり判定条件そのものには意味がありません。目的は次の「分配」にあります。
T extends any ? ... : never も同じ目的で使われ、挙動は実質同じです。unknown の方が「型安全寄りのトップ型」という意図が伝わりやすいため、最近は unknown が好まれます。
分配条件型(Distributive Conditional Types)
条件型には次のルールがあります。
チェック対象が裸の型パラメータ(
Tそのもの)で、そこにユニオン型が渡されると、条件型はユニオンの各メンバーに個別に適用され、結果がユニオンで結合される。
type ToArray<T> = T extends unknown ? T[] : never;
type R = ToArray<string | number>;
// 内部的には:
// (string extends unknown ? string[] : never)
// | (number extends unknown ? number[] : never)
// = string[] | number[]
// OK
const a1: R = ['a' , 'b'];
const a2: R = [1 , 2];
// エラー(string[] にも number[] にも代入できない)
const a3: R = ['a', 1];
条件型を使わない場合と比べると違いがはっきりします。
type ToArrayPlain<T> = T[];
type R2 = ToArrayPlain<string | number>; // (string | number)[]
// いずれもOK
const b1: R2 = ['a', 'b'];
const b2: R2 = [1, 2];
const b3: R2 = ['a', 1];
注意点
1.チェック対象に never を渡すと結果は never
never は「メンバー0個のユニオン」とみなされ、分配先がないので結果も never になります。
type ToArray<T> = T extends unknown ? T[] : never;
type X = ToArray<never>; // never(never[] ではない)
2.分配されるのは「裸の」型パラメータだけ
type NotDist<T> = Promise<T> extends unknown ? T[] : never;
// Promise<T>は「裸の」型パラメータではないため、分配されない
type Y = NotDist<string | number>;
// 型パラメータを経由せず、ユニオン型を直接書いた場合も分配は起こらない
// そのため結果は(string | number)[]
まとめ
「常にtrueの条件」を挟むことで、変換ロジックはそのままに分配だけを発動させる、というのがこの構文の正体です。
実用例
ユニオン型に対する Omit(DistributiveOmit)
1.やりたいこと
kind で判別する図形のユニオン型 Shape があり、各メンバーには共通の id が付いています。
type Shape =
| { kind: "circle"; radius: number; id: string }
| { kind: "square"; size: number; id: string };
ここから id だけを取り除き、それ以外(kind と各図形固有のフィールド)はそのまま残した型 を作ります。期待する結果は次の形です。
// | { kind: "circle"; radius: number }
// | { kind: "square"; size: number }
2.NG例
type Bad = Omit<Shape, "id">;
// { kind: "circle" | "square" }
radius も size も消え、kind だけのオブジェクト型になってしまいます。
組み込みの Omit は次のように定義されています。
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
T は条件型のチェック対象ではないため分配は起こらず、Shape は ユニオン全体が一塊 として扱われます(Exclude は条件型ですが、分配されるのは keyof T であって T ではありません)。その結果、次の順で情報が失われます。
①keyof Shape は共通キーだけになる
ユニオン型の値に対して確実にアクセスできるのは、すべてのメンバーが持つプロパティだけです。そのため keyof (A | B) は「AとBの両方に存在するキー」になります。
keyof Shape // "kind" | "id"
// radius は circle にしかない、size は square にしかないので含まれない
②Exclude で id を除く
Exclude<"kind" | "id", "id"> // "kind"
③Pick で残ったキーだけ取り出す
Pick<Shape, "kind">
// { kind: Shape["kind"] } = { kind: "circle" | "square" }
つまり、ユニオンを一塊として扱った時点で、共通フィールド以外(radius, size)は①の段階で廃棄されてしまうのが原因です。
更に、結果が1つのオブジェクト型にまとめられるため、「kind が "circle" なら radius がある」というメンバーごとの対応関係(判別可能ユニオンとしての性質)も失われます。
3.OK例
T extends unknown ? ... : never で分配させ、メンバーごとに Omit を適用 します。
type DistributiveOmit<T, K extends PropertyKey> =
T extends unknown ? Omit<T, K> : never;
type Good = DistributiveOmit<Shape, "id">;
// 内部的には:
// Omit<{ kind: "circle"; radius: number; id: string }, "id">
// | Omit<{ kind: "square"; size: number; id: string }, "id">
//
// 結果:
// | { kind: "circle"; radius: number }
// | { kind: "square"; size: number }
各メンバー単体で見れば keyof は "kind" | "radius" | "id" のように全フィールドを含むため、id 以外はすべて残ります。結果もユニオンのまま保たれるので、kind による型の絞り込みもそのまま使えます。
function area(s: Good) {
if (s.kind === "circle") return Math.PI * s.radius ** 2; // OK
return s.size ** 2; // OK
}
[T] extends [...]
条件型の分配(distribution)を止めて、ユニオン型を一塊のまま判定するためのイディオムです。前節の T extends unknown ? ... : never とちょうど逆の役割です。
問題となる挙動
裸の型パラメータ T に対する条件型は、ユニオンが渡されると自動的に分配されます。
type IsString<T> = T extends string ? true : false;
type A = IsString<string | number>;
// = (string extends string ? true : false)
// | (number extends string ? true : false)
// = true | false
// = boolean
「string | number 全体が string に代入可能か?」を知りたかったのに、メンバーごとの結果が混ざって boolean という曖昧な答えになってしまいます。
タプルで包むと分配が止まる
type IsString<T> = [T] extends [string] ? true : false;
type A = IsString<string | number>; // false
type B = IsString<"hello">; // true
分配が起こる条件は「チェック対象が裸の型パラメータであること」。[T] は T そのものではなく「T を要素に持つタプル型」なので、この条件を満たさず分配が発生しません。その結果、[string | number] extends [string] という1回だけの判定になります。
タプルの代入可能性は要素ごとに判定されます(共変)。つまり
[X] extends [Y] ⟺ X extends Y
がほぼ成り立つので、判定内容はそのままに、分配だけを無効化できるわけです。
両辺を同じ形で包むのがポイントで、[T] extends string のように片側だけ包むと「タプル型が string に代入可能か」という判定になり、常に false になってしまいます。
まとめ
[T] extends [U] は、判定内容は T extends U と同じまま、ユニオンを分配せずに丸ごと1回で判定するための書き方です。
代表的な使い道
never の判定(IsNever)
最も有名な用途です。裸の T だと never は「メンバー0個のユニオン」として扱われ、分配先がないので結果自体が never になってしまいます。
type IsNeverBad<T> = T extends never ? true : false;
type X = IsNeverBad<never>; // never ← true でも false でもない
type IsNever<T> = [T] extends [never] ? true : false;
type Y = IsNever<never>; // true
type Z = IsNever<string>; // false
ユニオン全体が条件を満たすかの判定
type AllNumbers<T> = [T] extends [number] ? "yes" : "no";
type R1 = AllNumbers<1 | 2 | 3>; // "yes"
type R2 = AllNumbers<1 | "a">; // "no"(分配すると "yes" | "no" になる)