あなたが知らないかもしれない TypeScript の豆知識
はじめに
TypeScript で型エラーを出さずに満足に実装はできるけど、
「never
型とかよくわかってない😇 」
みたいな人向けの記事です。これを読んだら TypeScript 中級者くらいにはなれるかも??(無責任)
今回取り扱うのは以下の5つのトピックです
-
void
型の危うい挙動 - 条件型 (conditional type)を使いこなす
-
Object
,{}
型を使うべからず -
never
型 -
Tuple
の実態
本記事は CyberAgent22 Advent Calender 14日目の投稿です。
void
型の危うい挙動
そもそも `void` 型とは...?
void
型に馴染みがあまりない人向けに undefined
型との違いを説明しておきます。ご存じの方は次の節までスキップをおすすめします。
関数の返り値が無いことを示す void
型ですが、 JavaScript の世界においては「返り値が無い」 ことは 「undefined
を返す」 ことを暗黙的に意味します。つまり、関数の返り値の型にそれぞれvoid
, undefined
を定義した以下のようなコードがエラーなく通ります。
function voidFunc (arg: unknown): void {
return undefined
}
function undefinedFunc (arg: unknown): undefined {
return undefined
}
では、 undefined
と void
型の違いはなんでしょうか?
違いの一つは、void
は undefined
に割り当てることができないというものです。
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.forEach
と Array.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 はこのような実装を許容するためにエラーを出さないようにしているようです。
詳細はこちらをご覧ください。
この説明への筆者の意見
これを読むと、
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です。
- 型 T を受け取り、それを条件節で分配する
- 元の型 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 でも言われていますね。
この辺りの使い分けは以下の記事がとても勉強になりました🙏
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
こちらの解説が非常にわかりやすいです。
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 のプロパティを全て持つ)
公式ドキュメントをご覧ください。
これを理解すると、 Tuple の型の Union を次のように取得できるのも理解できますね。
type TupleExample = [ number, string, boolean ]
type UnionedTuple = TupleExample[number] // number | string | boolean
おわりに
以上、5つのトピックについて解説しました。
もし参考になったという方はいいねしていただけると嬉しいです!
Twitter もつい最近始めたのでフォローしていただけると嬉しいです
また、この辺りの知識は type-challenges を通じて学びました。みなさんもぜひお試しください。
より良い TypeScript ライフをお送りください〜!
Discussion