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

参考資料

TypeScript の構造的部分型とプリミティブ型について

TypeScript の構造的部分型という概念は、直感的で分かりやすい。
だが一方で、Freshness やプリミティブ型の挙動など、よく理解しておかないと戸惑ってしまうような挙動も多い。
例えば、なぜ以下のコードがエラーにならないのか、最初は分からなかった。

const number: {} = 1;
const string: {} = 'a';
const boolean: {} = true;
const symbol: {} = Symbol('a');

また、下記のコードでbarだけがエラーになるのも分かりづらい。

const point = {x: 1, y: 1};
const foo: {x: number} = point; // OK
const bar: {x: number} = {x: 1, y: 1}; // Error
const empty: {} = {x: 1, y: 1}; // OK

この記事では、これらの挙動を理解できるようになることをゴールとし、その過程を通して、構造的部分型について学んでいく。

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

構造的部分型

TypeScript では構造的部分型という考え方を採用している。
具体的には、2 つの構造体のプロパティをチェックし、それが一致すれば両者には互換性があると見做す。

下記のコードでは、Personという型とBookという型を定義している。
それぞれは独立した存在であり、継承関係などはない。
そしてgreetという関数は、Person型を引数として受け取る。

interface Person {
  name: string;
}

interface Book {
  name: string;
  category: string;
}

const greet = (person: Person): void => {
  console.log(`Hello! I'm ${person.name}.`);
};

しかしgreetPersonではなくBookを渡しても、エラーにはならない。

const alice: Person = {name: 'Alice'};
greet(alice); // Hello! I'm Alice.

// greet に Book を渡してもエラーにならない
const santai: Book = {name: '三体', category: 'Science Fiction'};
greet(santai); // Hello! I'm 三体.

これは、BookPersonと互換性があると見做されるためである。
具体的には、name: stringというプロパティさえ持っていれば、その型はPersonと互換性があると判断される。

Booknameの他にcategoryというプロパティを持っているが、これは問題にはならない。
TypeScript の構造的部分型では、必要なプロパティ(Personの場合はname)さえ存在すれば、それ以外のプロパティの有無は問わないためである。
そして「余分なプロパティは問題にならない」という性質上、空のオブジェクトは、どんなオブジェクトに対しても互換性を持つ。

const foo = {x: 1, y: 2, z: 3};

// OK
const onlyX: {x: number} = foo;
const onlyY: {y: number} = foo;
const onlyZ: {z: number} = foo;
const empty1: {} = foo;
const empty2: {} = {bar: 'a'};
const empty3: {} = {array: [1]};

// Error
const onlyA: {a: number} = foo;

プロパティの過剰が問題にならない一方、プロパティの不足は問題になる。
例えばBookの場合、nameだけでなくcategoryもなければ、条件を満たさない。

interface Person {
  name: string;
}

interface Book {
  name: string;
  category: string;
}

const alice: Person = {name: 'Alice'};
const santai: Book = {name: '三体', category: 'Science Fiction'};

// OK
const foo: Person = santai;

// Error
const bar: Book = alice; // Property 'category' is missing in type 'Person' but required in type 'Book'.

Freshness

TypeScript の構造的部分型では余分なプロパティは問題にならない、と書いてきたが、例外もある。
それが Freshness と呼ばれる挙動で、オブジェクトリテラルに対しては型のチェックが厳しくなり、余分なプロパティが許容されなくなる。

下記のコードでは全く同じ値をgreetに渡しているが、一度変数に代入しておいた場合はエラーにならず、オブジェクトリテラルをgreetに渡した場合のみエラーになっている。

interface Person {
  name: string;
}

interface Book {
  name: string;
  category: string;
}

const greet = (person: Person): void => {
  console.log(`Hello! I'm ${person.name}.`);
};

const alice: Person = {name: 'Alice'};
const santai: Book = {name: '三体', category: 'Science Fiction'};

greet(santai); // OK
greet({name: '三体', category: 'Science Fiction'}); // Error

なぜこのような紛らわしい挙動になっているのか分からなかったが、以下の記事を読んで納得できた。

typescript-jp.gitbook.io

オブジェクトリテラルを渡す際にわざわざ余分なプロパティを書いていればそれは、勘違いやタイプミスである可能性が高い。そのため、エラーを出すようにしている。

また、可読性を高める効果もある。
以下のようなコードを許可してしまうと、わざわざcategoryを渡しているのだから、greetcategoryを渡すことに意味があるように読めてしまう。だが実際には、この値はgreet内で全く使われない。

greet({name: '三体', category: 'Science Fiction'});

{} には Freshness が適用されない

ややこしいのだが、{}には Freshness が適用されない。
なぜこのような挙動をしているのかは、分からなかった。

// y という余分なプロパティをオブジェクトリテラルで渡しているため、エラーになる
const onlyX: {x: number} = {x: 1, y: 2};

// x という余分なプロパティをオブジェクトリテラルで渡しているが、エラーにならない
const empty: {} = {x: 1};

プリミティブ型と構造的部分型

{}型はどんなオブジェクトも許容するだけでなく、nullundefined以外のプリミティブ型も許容する。

// OK
const number: {} = 1;
const string: {} = 'a';
const boolean: {} = true;
const symbol: {} = Symbol('a');

// Error
const foo: {} = null;
const bar: {} = undefined;

この挙動を理解するためにまず、型としてのプリミティブ型について調べていく。

keyof演算子は、プロパティ名の Union Types を返す。
これを利用することで、対象となる型がどんな名前のプロパティを持っているのか調べることができる。

interface Point {
  x: number;
  y: number;
}

// 以下の結果から、Point には x と y の 2 つのプロパティがあることを確認できる
// type keyOfPoint = "x" | "y"
type keyOfPoint = keyof Point;

これをプリミティブ型に対して使ってみる。

// type keyOfNumber = "toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString"
type keyOfNumber = keyof number;

// type keyOfString = number | "toString" | "charAt" | "charCodeAt" | "concat" | 以下略
type keyOfString = keyof string;

// type keyOfBoolean = "valueOf"
type keyOfBoolean = keyof boolean;

// type keyOfSymbol = "toString" | "valueOf"
type keyOfSymbol = keyof symbol;

この結果を見ると、プリミティブ型はプロパティを持った構造体でもある、と言える。
そしてこのプロパティは、各プリミティブ型に対応するインスタンスオブジェクトのプロトタイプメソッドと、ほぼ一致する。

例えば数値型の場合、constructor以外は一致している。

const one = new Number(1);
const porototype = Object.getPrototypeOf(one);

// 'constructor', 'toString', 'toFixed', 'toExponential', 'toPrecision', 'valueOf', 'toLocaleString'
console.log(Object.getOwnPropertyNames(porototype));

// type keyOfNumber = "toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString"
type keyOfNumber = keyof number;

若干の差異はあるが、他のプリミティブ型も同様の結果になる。
余談だが、keyof stringの結果にnumberが含まれるのは、文字列に対してはブラケット記法で数値を渡せるためである。

'abc'[0] // 'a'

ここまでの内容をまとめると、プリミティブ型(nullundefinedを除く)は、型のチェックを行う際、プリミティブ型そのものとしてだけでなく、インスタンスオブジェクトとしても、扱われる。どちらに対しても互換性を持った型になる。

以下のoneは、number型であると同時に、toFixedというメソッドを持った構造体でもある。
数値型はtoFixedメソッド以外にもtoExponentialなどのメソッドを持つが、構造的部分型では余分なプロパティは問題にならないため、HasToFixed型と互換性があると判断される。
そして、オブジェクトリテラル以外は Freshness の対象外なので、値を直接代入しても問題ない。

const one = 1;

interface HasToFixed {
  toFixed: Function;
}

const foo: number = one; // OK
const bar: HasToFixed = one; // OK
const baz: HasToFixed = 1; // OK
const qux: HasToFixed = new Number(1); // OK

なお、インスタンスオブジェクトはプリミティブ型に対して互換性を持たないので、注意する。

const bar: Number = 1; // OK
const foo: Number = new Number(1); // OK
const baz: number = new Number(1); // Error

プリミティブ型(nullundefinedを除く)は構造体としても扱われる、そして、{}はどのような内容の構造体でも受け入れる。
そのため冒頭で示したように、{}はプリミティブ型(nullundefinedを除く)と互換性を持つのである。

// OK
const number: {} = 1;
const string: {} = 'a';
const boolean: {} = true;
const symbol: {} = Symbol('a');

// Error
const foo: {} = null;
const bar: {} = undefined;

ちなみに、「プリミティブな値は受け入れず、オブジェクトしか受け入れない型」は、object型で表現できる。

// OK
const foo: object = {};
const bar: object = [];
const baz: object = new Number(1);

// Error
const qux: object = 1;
const quux: object = 'a';

参考資料