0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScript覚え書き #2

0
Posted at

表現の改善に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" になる)
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?