👻

あなたが知らないかもしれない TypeScript の豆知識

2021/12/13に公開約12,200字

はじめに

TypeScript で型エラーを出さずに満足に実装はできるけど、
never 型とかよくわかってない😇 」
みたいな人向けの記事です。これを読んだら TypeScript 中級者くらいにはなれるかも??(無責任)

今回取り扱うのは以下の5つのトピックです

  • void 型の危うい挙動
  • 条件型 (conditional type)を使いこなす
  • Object, {} 型を使うべからず
  • never
  • Tuple の実態

本記事は CyberAgent22 Advent Calender 14日目の投稿です。

https://adventar.org/calendars/6671

void 型の危うい挙動

そもそも `void` 型とは...?

void 型に馴染みがあまりない人向けに undefined 型との違いを説明しておきます。ご存じの方は次の節までスキップをおすすめします。
関数の返り値が無いことを示す void 型ですが、 JavaScript の世界においては「返り値が無い」 ことは 「undefined を返す」 ことを暗黙的に意味します。つまり、関数の返り値の型にそれぞれvoid, undefined を定義した以下のようなコードがエラーなく通ります。

function voidFunc (arg: unknown): void {
  return undefined
}

function undefinedFunc (arg: unknown): undefined {
  return undefined
}

では、 undefinedvoid 型の違いはなんでしょうか?

違いの一つは、voidundefined に割り当てることができないというものです。

const a: void = undefined // ok

// console.log の返り値の型は `void`
function voidFunc2 (arg: unknown): void {
  return console.log('void func')
}

function undefinedFunc2 (arg: unknown): undefined {
  return console.log('undefined func') // エラー: Type 'void' is not assignable to type 'undefined'.
}

したがって、使い分けとしては以下のようになります。

  • 関数の返り値が無い: void を使う
  • 関数の返り値に undefined を含む: undefined をユニオンタイプに含める (ex. number | undefined

TypeScript において関数の定義の仕方は様々ありますが、よくある二つのやり方で戻り値が void の関数を定義してみます。

// example1: 関数型を事前に定義してそれを実装する
type VoidFunc = (arg: unknown) => void
const pretypedVoidFunc: VoidFunc = (arg) => {
  ...
}

// example2: 関数型をその場で明示しながら実装する
const voidFunc = (arg: unknown): void => {
  ...
}

やり方こそ違いますが、これらは同じ動作をする...と思いますよね?

実はここに微妙な違いがあります。

ここで、この関数の中で適当な値を返す、つまり、返り値の型定義の void に矛盾するようなコードを書いてみます。すると、少し不思議なことが起こります。

// example1
type VoidFunc = (arg: unknown) => void
const pretypedVoidFunc: VoidFunc = (arg) => {
  return 100
}

// example2
const voidFunc = (arg: unknown): void => {
  return 100 // Error: Type 'number' is not assignable to type 'void'.
}

返り値の型に void を指定しているので、両方ともエラーが出るかと思いきや、一つ目の例ではエラーが出ません。(自分の目で確かめたい方はこちら

例えば、このような forEach 構文を使うコードを考えてみましょう。

const items: number[] = []
const numList = [1,2,3,4,5]
numList.forEach((num) => items.push(num))

これは問題なく動作します。何の問題も無さそうに見えます。
ここで Array.forEachArray.push の型定義を見てみると...

forEach(callbackfn: (value: T, index: number, array: T[]) => void, thisArg?: any): void; // コールバック関数の返り値は `void`
Array<any>.push(...items: any[]): number // 返り値は `number`

つまり、さっきの numList.forEach((num) => items.push(num)) も 返り値の型が void に対して number を割り当てていることになります。先ほどの例と同じですね。

TypeScript はこのような実装を許容するためにエラーを出さないようにしているようです。
詳細はこちらをご覧ください。

https://github.com/Microsoft/TypeScript/wiki/FAQ#why-are-functions-returning-non-void-assignable-to-function-returning-void
この説明への筆者の意見

これを読むと、

Another way to think of this is that a void-returning callback type says "I'm not going to look at your return value, if one exists".
void を返す関数のもう一つの捉え方は、「もしその返り値が存在してもそれを無視することを明示する型」というものだ

とありますが、これもまた不正確な言い方だとは思います。なぜなら、先ほどの二つ目の例のようなコードはきちんと void チェックをしてくれるので。

const voidFunc = (arg: unknown): void => {
  return 100
}

このような危うい挙動を示す void ですが、これのせいでコンパイルエラーをすり抜けて実行時エラーが出るようなことはまずないと思っています。それは関数の返り値の型は void として処理されるので、その値に対して何かしようとするとコードを書いた時点で TypeScript に怒られるためです。

ただ、逆に言うとTSエラーが出ながらも無視して実行すると以下のコードはきちんと動きます😇

type ArrowVoidFunc = (arg: unknown) => void
const arrowVoidFunc: ArrowVoidFunc = (arg) => {
  return 100000
}

const response = arrowVoidFunc('hoge') // response: void
console.log(response.toLocaleString()) // エラー: Property 'toLocaleString' does not exist on type 'void'.
// -> 100,000 が出力される

こちらで実際の動作を確認できます

[追記]
公式にも以下の記載がありました

Using void is safer because it prevents you from accidentally using the return value of x in an unchecked way:
https://www.typescriptlang.org/docs/handbook/declaration-files/do-s-and-don-ts.html#return-types-of-callbacks

以上、 void 型の危うい挙動でした。一応知っておくと将来何かしらのバグ発見に役に立つかも?

条件型 (conditional type) を使いこなす

そもそも条件型(conditional type)とは

JavaScript における三項演算子の書き方で型についての条件分岐ができます。

type isEmptyArray<T extends unknown[]> = T['length'] extends 0 ? true : false
type Hoge1 = isEmptyArray<[]> // Hoge: true
type Hoge2 = isEmptyArray<[1]> // Hoge: false

まず、この条件節に Union Type (ex. Foo | Bar) が入ってきた時の動きを確認しておきます。

条件節に入ってきた Union Type は分配されます。
例として、 Union から特定の型を取り除く Exclude 型(ユーティリティタイプの一つ)の動きを詳しくみてみましょう。

type Excluded = Exclude<'Foo'|'Bar'|'Hoge', 'Bar'> // Union Types を分配する
              = Exclude<'Foo','Bar'> | Exclude<'Bar','Bar'> | Exclude<'Hoge','Bar'>
	      = 'Foo' | never | 'Bar' // never は他の型とのユニオンをとると消える
	      = 'Foo' | 'Bar'

例題

さて、ここでは与えられた型が Union Type かどうかを判別する IsUnion 型を作ることを考えます。

type IsUnion<T> = ... // T が Union Type かどうかを判別したい

皆さんも一旦立ち止まって考えてみてください。

Union Type を判別するにはどうすれば良いでしょうか?
先ほどの 「条件節に入ってきた Union Type は分配される」 ルールを使えば、これらを区別できることができます。

  • Union Type:条件節において分配されるので初めに与えた型とは違う型
  • 単一の型:条件節でも分配されずに元と同じ型

これを踏まえた方針としては以下の通りです。後で具体例を挙げて説明するのでまずは雰囲気だけ理解してもらえればokです。

  1. 型 T を受け取り、それを条件節で分配する
  2. 元の型 T が分配後の型を extends しているなら、それは条件節において型が分配されていないことを意味するので false , そうでないなら true

これを実際にコードに落とし込むと次のようになります。

type IsUnion<T, U=T> = T extends T
                       ? [U] extends [T]
                         ? false
                         : true
                       : never 

type FooBar = IsUnion<'foo'|'bar'> // FooBar: true
type Foo = IsUnion<'foo'> // Foo: false

解説

T extends T

まずはこれ。絶対に true にしかならない条件節。
これを使う理由は、「T を条件節に入れることで Union Type を分配できるから」 です。もはや条件型を、分岐させるためではなく、条件節で型を分配させるために使っています。
例えば、 'foo'|'bar' がこの条件節に入ると、'foo''bar' に分配され、それぞれこの一つずつがこの後の処理に入っていきます。

type IsUnion<T, U=T> = T extends T // 1個目のTには分配後、2個目には分配前の型が入る ex. 'foo' extends 'foo' | 'bar'
                       ? [U] extends [T]
                         ? false
                         : true
                       : never 

この後の処理で分配後の T が元の T と同じかどうかを確かめていきます。

IsUnion<T, U=T>

次に、なんか引数増えてね? と思われてそうなこれ。
これを使う理由は、分配前の T を保存しておきたいからです。
条件節で分配された T は、その後の処理では 分配後の型が使われます(コード上は T のままなのがややこしい)。しかし、今回は初めに受け取った型が分配後の型を extends しているかどうかを検証する必要があるので、どこかに最初の型引数 T を保存しておかなければいけません。

そこで、型引数 U を追加し、デフォルト引数として T を与えることで最初の型引数 T を保存することができます。

type IsUnion<T, U=T> = T extends T 
                       ? [U] extends [T] // この行の T は分配された後の T。分配前の T をここでは使いたいので初めに U に保存しておく。
                         ? false
                         : true
                       : never 

[U] extends [T]

最後にこれ、なんで [] で括ってるのか。
これは条件節で Union Type を分配しないためです。

やりたいことは、 元の型 extends 分配後の型 ? false : true ですが、普通に U extends T とするとせっかく保存しておいた U がまた分配されてしまって期待した動作になりません。

そこで、配列の中にぶち込んであげることで、 Union Type が分配されずにそのまま配列の中に入ります。

type IsUnion<T, U=T> = T extends T 
                       ? [U] extends [T] // Union が入ってきた場合は ['foo'|'bar'] extends ['foo'] になり、この条件節は false になり、 true を返す方の条件分岐に行く
                         ? false
                         : true
                       : never 

動きの流れ

先ほどのコードに 'foo'|'bar''foo' を入れた時の動きを追ってみます。

type IsUnion<'foo'|'bar'> = type IsUnion<'foo'|'bar', 'foo'|'bar'>
                          = 'foo' extends 'foo' | 'bar' // true
                              ? [ 'foo'|'bar' ] extends [ 'foo' ] // false
                                ? false 
				: true
                              : never
                            |'bar' についても同じように)
			    
type IsUnion<'foo'> = type IsUnion<'foo', 'foo'>
		    = 'foo' extends 'foo'  // true
		        ? [ 'foo' ] extends [ 'foo' ] // true 
			  ? false 
			  : true
		        : never

まとめ

非常に強力な機能である条件型(Conditional Type) を使いこなしていきましょう!

Object, {} 型を使うべからず

ここまで重い話が続いたのでここからは少し軽めの話題を。

TypeScript における Object, {}型 は null, undefined 以外の全ての値を受け入れます。というのも、 JavaScript の世界では基本的に全てがオブジェクトであるためです。
一方で、object(小文字始まり) 型を使うとオブジェクトだけに限定された型を表現してくれます。名前が紛らわしいですが、こちらのobject型を使うようにしましょう。

const a: Object = 1; // ok...
const b: {} = 1 // ok...
const c: object = 1 // エラー!!!

type isEmptyOK<T extends {}> = keyof T extends never ? true : false
type isEmptyNG<T extends object> = keyof T extends never ? true : false
type isEmpty1 = isEmptyObject<100> // オブジェクトではない 100 を渡しているのにエラーが出ない...
type isEmpty2 = isEmptyObject2<100> // きちんとエラー!!!

空の interface は使うな、とこの FAQ でも言われていますね。

この辺りの使い分けは以下の記事がとても勉強になりました🙏

https://blog.yux3.net/entry/2017/06/08/202859

never

never 型。型の世界の縁の下の力持ち的な存在です。
絶対に返らない関数の返り値の型として使われたり、 条件型で使われたりしますね。

function throwError(message: string): never {
  throw new Error(message);
}

function loop(): never {
  while(true) {
    console.log('looping...')
  }
}

type FilterString<T> = T extends string ? T : never

さて、ここで与えれた型 T が never かどうかを判別する isNever 型を作ることを考えてみましょう。
number かどうかを判別する型は type IsNever<T> = T extends never ? true : false で作れることを考えると、 never についてもこれでいけそうな気がしますが...

type IsNever<T> = T extends never ? true : false

type Foo = IsNever<never> // Foo: never (なぜ? true か false しか返さないはずなのに!!!)
type Bar = IsNever<100> // Bar: false

これが動作しない理由は never extends never が動作しないことにあります。
理由はこの辺りです。

  • Union Type における never は無視される
  • 条件節において分配対象が何もない場合はそもそも条件型が無視される
type Hoge = number | never // Hoge: number
type Foo = IsNever<never>
         = never extends never ? true : false <- never は空の Union として扱われ、 never が返される
	 = never

これを解決するためには、先ほどの IsUnion でもやったテクニック
「Union を配列で固定する」
を使いましょう。

type IsNever<T> = [T] extends [never] ? true : false

こちらの解説が非常にわかりやすいです。

https://github.com/type-challenges/type-challenges/issues/614#:~:text=Explanation-,[T] extends [never],-What in the

Tuple の実態

Tuple は Array よりも非常に強力な型で、タプル内部のどのインデックスにどの型が入るかを指定できます。
どの型がどの場所に入るかが事前にわかっている場面ではこのように書くことができます。

type TupleExample = [ number, string, boolean ]

それではタプルを動的に作る場面ではどのように作れば良いでしょうか?
例として、タプルを受け取って中身を全て Promise に変換したタプルを返す MapPromise はこんな風に作ることができます。

type MapPromise<T extends unknown[]> = {
  [Index in keyof T]: Promise<T[Index]>
}
type Hoge = MapPromise<[number, boolean]> // Hoge: [Promise<number>, Promise<boolean>]

「いやいや、なんで Tuple を返すはずの MapPromise がオブジェクトを返してるんだ???」

そう思った方もいらっしゃると思います。いよいよ Tuple の実態を知るときが来ました。 実は Tuple の中身は以下のものと等しいです。

type TupleExample = [ number, string, boolean ]

type TupleExample2 = {
  'length': 3,
  0: number,
  1: string,
  2: boolean,
  // 以下 Array のメンバ群
  slice(start?: number, end?: number): Array<string | number>;
  ...
}

つまり、オブジェクトの中に 'length' プロパティで Tuple の長さを、数字インデックスにそれぞれの型を入れているんですね。(それ以外は  Array のプロパティを全て持つ)

公式ドキュメントをご覧ください。

https://www.typescriptlang.org/docs/handbook/2/objects.html#:~:text=Other than those length checks%2C simple tuple types like these are equivalent to types which are versions of Arrays that declare properties for specific indexes%2C and that declare length with a numeric literal type

これを理解すると、 Tuple の型の Union を次のように取得できるのも理解できますね。

type TupleExample = [ number, string, boolean ]
type UnionedTuple = TupleExample[number] // number | string | boolean

おわりに

以上、5つのトピックについて解説しました。
もし参考になったという方はいいねしていただけると嬉しいです!
Twitter もつい最近始めたのでフォローしていただけると嬉しいです

また、この辺りの知識は type-challenges を通じて学びました。みなさんもぜひお試しください。

https://github.com/type-challenges/type-challenges

より良い TypeScript ライフをお送りください〜!

Discussion

ログインするとコメントできます