30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

TypeScript の条件型(Conditional Type)と infer キーワード

TypeScript には条件型(Conditional Type)という機能があり、これを使うと型定義に条件分岐を持ち込むことができる。

この記事の内容は TypeScript のv3.9.5で動作確認している。

条件型の基本

T extends U ? A : Bが、条件型の構文。
TUに割り当て可能なときはA、そうでないときはBを意味する。

そしてtype Example<T> = T extends U ? A : B;のように、ジェネリック型と組み合わせて使う。

ジェネリック型については、以前記事を書いた。

numb86-tech.hatenablog.com

条件型とジェネリック型の組み合わせ例を示す。
以下のIsStringは、渡された型引数Tstringに割り当て可能なときはtrueを、不可能なときはfalseを返す。

type IsString<T> = T extends string ? true : false;

type A = IsString<string>; // type A = true
type B = IsString<"a">; // type B = true
type C = IsString<number>; // type C = false

分配法則

条件型は、分配法則に従う。
そのため、T extends U ? A : BTが共用体型のとき、Tのそれぞれの要素に対して条件型が展開される。

T1 | T2 extends U ? A : B
// ↓
(T1 extends U ? A : B) | (T2 extends U ? A : B)

この性質を使うことで、共用体型の中から特定の条件を満たした要素のみを取り出す、ということが可能になる。

例えば以下のExtractStringは、渡された値の中から、string及びそのサブタイプを取り出す。

type ExtractString<T> = T extends string ? T : never;

type A = ExtractString<1 | "a" | "b">; // type A = "a" | "b"
type B = ExtractString<1 | 2>; // type B = never

ExtractString1 | "a" | "b"が渡された場合、以下のように計算が進む。

ExtractString<1 | "a" | "b">
// ↓
1 | "a" | "b" extends string ? T : never
// ↓
(1  extends string ? T : never) | ("a" extends string ? T : never) | ("b" extends string ? T : never)
// ↓
never | "a" | "b"

neverの性質上、T | neverTになるので、最終結果は"a" | "b"になる。

neverの性質やそれを使ったテクニックについては、下記を参照。

numb86-tech.hatenablog.com

TypeScript の組み込みの条件型でも、このテクニックがよく使われている。

/**
 * Exclude from T those types that are assignable to U
 */
type Exclude<T, U> = T extends U ? never : T;

/**
 * Extract from T those types that are assignable to U
 */
type Extract<T, U> = T extends U ? T : never;

/**
 * Exclude null and undefined from T
 */
type NonNullable<T> = T extends null | undefined ? never : T;

infer キーワードによるキャプチャ

inferキーワードを使うと、条件にマッチした際にその一部分をキャプチャして利用することが可能になる。

T extends U ? A : Bの、Uの部分でinferを使う。
そうすると、条件にマッチしたときにその型をキャプチャし、それをAの部分で使えるようになる。

type ResolvedType<T> = T extends Promise<infer X> ? X : never;

type A = ResolvedType<Promise<1>>; // type A = 1
type B = ResolvedType<Promise<string>>; // type B = string
type C = ResolvedType<1>; // type C = never

inferは、ジェネリック型のようなものだと考えることができる。上記のResolvedTypeの定義で使っているXは、定義した時点では型が決まっていない。

ResolvedTypeに型引数が渡されると TypeScript は、その型をTにバインドする。そしてその型に応じて、Xが何であるかを推論するのである。
どのような型もXにバインドできない場合は、条件にマッチしなかったと判定され、neverが返される。
Xに具体的な型をバインドできた場合は条件にマッチしたと判定され、Xが返される。

inferも、組み込みの条件型で使われている。
以下は、関数の返り値の型を返すReturnTypeの例。

/**
 * Obtain the return type of a function type
 */
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

TypeScript の型ガードの注意点と解決法

TypeScript には型を推論する機能があり、条件分岐の際に自動的に型を絞り込んでくれる。この仕組みを型ガード(Type Guard)と呼ぶ。
ただし万能ではなく、自動的な絞り込みが機能しないケースもある。その場合、isを使って開発者が TypeScript に型を教えることで、解決できる。

この記事の内容は TypeScript のv3.9.5で動作確認している。

typeof による絞り込み

型を絞り込む方法はいくつかあるが、まずはtypeofを使った方法を紹介する。

const foo = (arg: number | string) => {
  const x = arg; // const x: string | number
  if (typeof arg === "number") {
    const y = arg; // const y: number
  } else {
    const z = arg; // const z: string
  }
};

上記のfooは、引数としてnumberstringを受け取る。
xの時点では、どちらなのかは判断できない。
だがif文でargtypeofをチェックしており、ifブロックのなかではargnumberであることが確定している。そして、TypeScript はそれを認識しており、ynumberだと自動的に推論してくれる。
そして、elseブロックのなかではargnumberではないことが確定しているため、型が絞り込まれ、stringであると推論される。

型の絞り込みは、if文以外の条件分岐でも機能する。

const foo = (arg: number | string) => {
  switch (typeof arg) {
    case "number": {
      const x = arg; // const x: number
      break;
    }
    case "string": {
      const y = arg; // const y: string
      break;
    }
    default: {
      const z = arg; // const z: never
    }
  }

  const a = typeof arg === "number" ? arg : null; // const a: number | null
};

switch文や三項演算子でも機能していることが分かる。

instanceof による絞り込み

typeofではなくinstanceofを使って絞り込むことも可能。

const foo = (arg: number | number[]) => {
  if (arg instanceof Array) {
    const x = arg; // const x: number[]
  } else {
    const y = arg; // const y: number
  }
};

argnumbernumber[]のいずかだが、Arrayのインスタンスである場合、numberである可能性が排除されるため、number[]に絞り込まれる。

in による絞り込み

inでプロパティの有無を調べることでも、絞り込みが行われる。

type A = {
  a: number
}
type B = {
  b: number
}

const foo = (arg: A | B) => {
  if ('a' in arg) {
    const x = arg; // const x: A
  } else {
    const y = arg; // const y: B
  }
};

argaというプロパティを持っている場合、argBである可能性が消えるため、Aに絞り込まれる。

型ガードはインラインで書かないと機能しない

型ガードは便利な機能だが、落とし穴も多い。

まず、条件式をインラインで書かないといけない。
例えば、以下のコードでは型ガードが機能する。

const foo = (arg: number | string) => {
  if (typeof arg === "number") {
    const x = arg; // const x: number
  }
};

しかし以下のコードでは、barにおいてもbazにおいても、型ガードが機能していない。
先程のfooの例と処理の内容は全く同じなのだが、機能しない。

const bar = (arg: number | string) => {
  const isNumber = (arg: number | string) => typeof arg === "number";

  if (isNumber(arg)) {
    const x = arg; // const x: string | number
  }
};

const baz = (arg: number | string) => {
  const isNumber = typeof arg === "number";

  if (isNumber) {
    const x = arg; // const x: string | number
  }
};

if文以外も同じで、インラインで書いたa以外では型ガードが機能していない。

const foo = (arg: number | string) => {
  const isNumber = (arg: number | string) => typeof arg === "number";

  const boolean = typeof arg === "number"

  const a = typeof arg === "number" ? arg : null; // const a: number | null
  const b = isNumber(arg) ? arg : null; // const b: string | number | null
  const c = boolean ? arg : null; // const c: string | number | null
};

ユーザー定義型ガード

isを使うことで、条件式を切り出すことが可能になる。
isは、開発者が TypeScript に対して型を教えるための機能で、「ユーザー定義型ガード」と呼ばれる。

関数の返り値を引数 is Tとアノテートすると、「その関数がtrueを返す場合は引数はTであり、falseを返す場合はTではない」と TypeScript に指示することになる。

以下のisNumberは、返り値をarg is numberとアノテートしているため、trueを返した場合はargnumberであると見做される。
そのため、ifブロックのなかではargnumberに絞り込まれている。

const bar = (arg: number | string) => {
  const isNumber = (arg: number | string): arg is number =>
    typeof arg === "number";

  if (isNumber(arg)) {
    const x = arg; // const x: number
  }
};

isを使っている関数で真偽値以外を返すと、エラーになる。

const bar = (arg: number | string) => {
  // Type '1' is not assignable to type 'boolean'.ts(2322)
  const isNumber = (arg: number | string): arg is number => 1;
};

この機能を使うことで、型ガードを関数として切り出すことが可能になるし、複雑な型ガードを実装することも可能になる。
しかしisもまた万能ではなく、注意点がいくつかある。

まず、ユーザー定義型ガードは条件式のなかで使う必要がある。
以下のコードでは、isNumberの結果をbooleanに代入しそれを条件式で使っているが、そうすると型ガードが機能しなくなってしまう。

const bar = (arg: number | string) => {
  const isNumber = (arg: number | string): arg is number =>
    typeof arg === "number";

  const boolean = isNumber(arg);

  if (boolean) {
    const x = arg; // const x: string | number
  }
};

また、isを使ったアノテートが実装と矛盾していたとしても、TypeScript はエラーを出さないので、開発者が注意を払わなければならない。

以下のisNumberは、arg is numberとするべき箇所を、arg is stringと書いてしまっている。
だがエラーにはならず、TypeScript はその指示を愚直に信じてしまう。
その結果、xは実際にはnumberなのだが、TypeScript はそれをstringだと判断してしまう。

const bar = (arg: number | string) => {
  const isNumber = (arg: number | string): arg is string =>
    typeof arg === "number";

  if (isNumber(arg)) {
    const x = arg; // const x: string
  }
};

オブジェクト型の絞り込み

オブジェクトに対して型の絞り込みを行う場合、気をつけなければならない点がさらに増える。

対比のためにまず、プリミティブの場合の挙動を見てみる。

以下のコードでは、argの型をnumberに絞り込んだ後、関数を受け取る関数であるfooを実行している。
fooに渡す関数のなかでargを使っているが、TypeScript はこれをnumberだと認識してくれている。

const foo = (callback: () => void) => {
  callback();
};

const bar = (arg: number | string) => {
  const a = arg; // const a: string | number

  if (typeof arg === "number") {
    const b = arg; // const b: number
    foo(() => {
      const c = arg; // const c: number
    });
  }
};

ifブロックのなかではargが絞り込まれているのだから、ごく自然な挙動だと思う。

しかし、argの型をオブジェクトにすると、直感に反する挙動になる。

以下のコードでは、argxというプロパティを持つオブジェクトであり、xの型はnumber | stringである。
そして、typeofを使った絞り込みを行っている。

type SomeObject = {
  x: number | string;
};

const foo = (callback: () => void) => {
  callback();
};

const bar = (arg: SomeObject) => {
  const a = arg.x; // const a: string | number

  if (typeof arg.x === "number") {
    const b = arg.x; // const b: number
    foo(() => {
      const c = arg.x; // const c: string | number
      const d = b; // const d: number
    });
    const e = arg.x; // const e: number
  }
};

bを見ると、絞り込みが機能していることが分かる。
enumberになっているため、ifブロックのなかではarg.xnumberと推論されているはずである。

しかしcを見ると、なぜか絞り込みが行われず、string | numberになってしまっている。

実はオブジェクトの場合、新しい関数のスコープが作られると、そのなかでは型ガードによる推論結果が失われてしまう。

type SomeObject = {
  x: number | string;
};

const bar = (arg: SomeObject) => {
  if (typeof arg.x === "number") {
    const x = arg.x; // const x: number
    () => {
      const y = arg.x; // const y: string | number
    };
    function baz() {
      const z = arg.x; // const z: string | number
    }
  }
};

絞り込んだ結果を保持したい場合は、先程のdのように、推論された値を変数(b)に入れておきそれを代入する必要がある。

あるいは、ユーザー定義型ガードを使うことでも解決できる。

type SomeObject = {
  x: number | string;
};

const foo = (callback: () => void) => {
  callback();
};

const xIsNumber = (arg: SomeObject): arg is { x: number } => {
  return typeof arg.x === "number";
};

const bar = (arg: SomeObject) => {
  const a = arg.x; // const a: string | number

  if (xIsNumber(arg)) {
    foo(() => {
      const c = arg.x; // const c: number
    });
  }
};

関数を入れ子にしても、問題なく機能する。

type SomeObject = {
  x: number | string;
};

const xIsNumber = (arg: SomeObject): arg is { x: number } => {
  return typeof arg.x === "number";
};

const bar = (arg: SomeObject) => {
  if (xIsNumber(arg)) {
    () => {
      const x = arg.x; // const x: number
      () => {
        const y = arg.x; // const y: number
      };
    };
  }
};