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

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

never 型を使った TypeScript のテクニック

「発生し得ない値」などのように説明されるnever型。
概念としては分かるのだが、実際にどのようなケースで使えばよいのかイメージできずにいた。
neverを使ったテクニックを調べていて多少のイメージは掴めてきたので、整理しておく。

動作確認は TypeScript のv3.7.5で行っている。

never 型の特性

まずはnever型がどういった型なのか、理解する。

決して発生し得ない値や型は、never型になる。
例えば以下のif文では、elseブロックは絶対に実行されないため、そのなかではfooneverになる。

const foo = true;

if (foo) {
  foo; // const foo: true
} else {
  foo; // const foo: never
}

「存在し得ない値」なので、どんな値も代入することはできない。

const foo: never = 1; // Error
const bar: never = undefined; // Error
const baz: never = null; // Error

そして、存在し得ない型であるという特性上、T | neverは必ずTになる。

type Foo = number | never; // type Foo = number

type Bar<T> = T | never;
type Baz = Bar<number>; // type Baz = number
type Qux = Bar<string>; // type Qux = string

存在し得ない型を Union Types に追加しても何の変化も生まれないため、このような結果になる。

type Foo = 1 | 2; // type Foo = 1 | 2
type Bar = 1 | 2 | 3; // type Bar = 1 | 2 | 3
type Baz = 1 | 2 | 3 | never; // type Baz = 1 | 2 | 3

以上の特性を利用したテクニックを 2 つ挙げる。

Type Guard と組み合わせる

neverと Type Guard を組み合わせることで、組み合わせなかった場合には見逃してしまうエラーを、TypeScript が検知できるようになる。

例としてSignal型とgetMessageFromSignal関数を実装する。
まずはneverを使わずに書いた場合。

type Signal = 'green' | 'red';

const getMessageFromSignal = (signal: Signal): string => {
  switch (signal) {
    case 'green': {
      return 'Go!';
    }
    case 'red': {
      return 'Stop!';
    }
    default: {
      throw new Error(`${signal} is not Signal.`);
    }
  }
};

getMessageFromSignalSignalを渡し、その値に応じて文字列を返す。
引数にSignal型以外の値を渡すと、TypeScript はエラーを吐く。
そのため例えばgreanのようにタイプミスしても、それに気付くことができる。

console.log(getMessageFromSignal('green')); // Go!
console.log(getMessageFromSignal('grean')); // Argument of type '"grean"' is not assignable to parameter of type 'Signal'.

特に問題ないように見えるが、Signalの値を増やしたときにgetMessageFromSignal側の対応を怠ると、TypeScript では検知できないエラーが発生してしまう。

以下ではSignalyellowを追加したが、getMessageFromSignal側ではその対応を行っていない。
そのためgetMessageFromSignalyellowを渡すと、defaultブロックが実行されてエラーが投げられる。

type Signal = 'green' | 'red' | 'yellow';

const getMessageFromSignal = (signal: Signal): string => {
  switch (signal) {
    case 'green': {
      return 'Go!';
    }
    case 'red': {
      return 'Stop!';
    }
    default: {
      throw new Error(`${signal} is not Signal.`);
    }
  }
};

だがyellowSignal型なので、getMessageFromSignalに渡しても TypeScript はエラーを出してくれない。
そのため、このコードを実際に実行したときに初めてエラーに気付くことになる。

// 型チェックを行ってもエラーにならない
console.log(getMessageFromSignal('yellow')); // Error: yellow is not Signal.

never型を使うことで、このエラーを TypeScript が検知してくれるようになる。
具体的には、defaultブロックのなかで明示的にneverを使う。

type Signal = 'green' | 'red' | 'yellow';

const getMessageFromSignal = (signal: Signal): string => {
  switch (signal) {
    case 'green': {
      return 'Go!';
    }
    case 'red': {
      return 'Stop!';
    }
    default: {
      const strangeValue: never = signal; // Type '"yellow"' is not assignable to type 'never'.ts(2322)
      throw new Error(`${strangeValue} is not Signal.`);
    }
  }
};

Type Guard によって型の絞り込みが行われていくため、defaultブロックのなかのsignalgreenでもredでもない値、つまりyellowになっている。
にも関わらずnever型に代入しようとしているため、TypeScript はエラーを吐く。そのため、コードの実行前にgetMessageFromSignalの考慮漏れに気付くことができる。

yellowケースを追加すればdefaultブロック内のsignalneverとなるため、エラーは消える。

type Signal = 'green' | 'red' | 'yellow';

const getMessageFromSignal = (signal: Signal): string => {
  switch (signal) {
    case 'green': {
      return 'Go!';
    }
    case 'red': {
      return 'Stop!';
    }
    case 'yellow': {
      return 'Warning!';
    }
    default: {
      const strangeValue: never = signal;
      throw new Error(`${strangeValue} is not Signal.`);
    }
  }
};

console.log(getMessageFromSignal('yellow')); // Warning!

以降、Signalが追加される度にエラーが出るため、getMessageFromSignalの考慮漏れを見落とすことがなくなる。

Conditional Types と組み合わせる

neverは Conditional Types と組み合わせることで、型の絞り込みに利用できる。

Conditional Types に対して Union Types を使うと、Union Types のそれぞれの型に対して Conditional Types が展開される。
以下の例だとgreenblackそれぞれに対してIsSignalが実行される。

type Signal = 'green' | 'red' | 'yellow';
type IsSignal<T> = T extends Signal ? true : false;
type Foo = IsSignal<'green' | 'black'>; // type Foo = boolean

以下が、IsSignal<'green' | 'black'>booleanになるまでの流れ。

IsSignal<'green' | 'black'>
↓
('green' extends Signal ? true : false) | ('black' extends Signal ? true : false)
↓
true | false
↓
boolean

この性質をneverと組み合わせると、既存の型から特定の型をフィルタリングすることができる。

以下のSignalFilterは、Signalが渡されたときはそれをそのまま返し、Signal以外を渡された場合はneverを返す。
これを使うことで、Signal以外の値を取り除くことができる。

type Signal = 'green' | 'red' | 'yellow';
type SignalFilter<T> = T extends Signal ? T : never;
type Foo = SignalFilter<'green' | 'orange' | 'black' | 'yellow'>; // type Foo = "green" | "yellow"

上記のFooの場合、"green" | never | never | "yellow"となるが、Union Types のなかのneverは無視されるため、"green" | "yellow"となる。

このテクニックは TypeScript 本体でも使われており、NonNullableは以下の実装になっている。

type NonNullable<T> = T extends null | undefined ? never : T;

nullundefinedneverを返すため、それ以外の値が残る。

type Foo = NonNullable<null | 0 | 1>; // type Foo = 0 | 1
type Bar = NonNullable<null | '' | undefined | true>; // type Bar = "" | true

参考資料