TypeScriptの型パズル初級の解答例を見て、なぜこのコードでこのエラーなのか考えていたら
私にはとてもいろんなことが学びになったので、共有します。
それではまず、TypeScriptの型パズル初級の問題を見てください。
TypeScriptの型パズル初級問題の内容
画像の性質を持つ型 RequireEither を作ってください。(TypeScript v3.7.5)
こちらはりょーさんが作成した問題です。(掲載許可は事前にとりました)
問題を解くためには、まずどんな性質を持つ型を作ればいいのか理解していないと無理です。
RequireEitherはどんな性質の型なのか
if文による型の絞り込み結果を見たらわかります。
func({}); // Error
func({ foo: 42 }); // 'foo' in hogeの時に、{foo: number}を返している。
func({ bar: 'some string' }); // 'bar' in hogeの時に、{bar: string}を返している。
func({ baz: true }); // 'baz' in hogeの時に、{baz: boolean}を返している。
func({ hogehoge: true }); // Error
このことから、RequireEitherという型はfoo | bar | baz
の性質を持つ型ということがわかりますね。
それをどう実現するのか私には、理解できなかったので大人しく解答例を見ました。
解答例
以下解答例です。( Playgroundで見たい方はこちら)
type RequireEither<Props, Keys extends keyof Props = keyof Props> =
Keys extends keyof Props ? { [K in Keys]: Props[K] } : never;
type Hoge = RequireEither<{
foo: number; bar: string; baz: boolean;
}>;
function func(hoge: Hoge) {
let n: number;
let s: string;
let b: boolean;
const { foo = 0, bar = '', baz = false } = hoge;
if ('foo' in hoge) {
n = hoge.foo; s = hoge.bar; b = hoge.baz;
}
if ('bar' in hoge) {
n = hoge.foo; s = hoge.bar; b = hoge.baz;
}
if ('baz' in hoge) {
n = hoge.foo; s = hoge.bar; b = hoge.baz;
}
if ('foo' in hoge && 'bar' in hoge) {
n = hoge.foo; s = hoge.bar; b = hoge.baz;
}
if ('foo' in hoge || 'bar' in hoge) {
n = hoge.foo; s = hoge.bar; b = hoge.baz;
}
}
func({});
func({ foo: 42 });
func({ bar: 'some string' });
func({ baz: true });
func({ hogehoge: true });
関数のコードはなんとなく理解できても大事な、最初のコードが全く何してるかわかりませんでしたw
ここです。
type RequireEither<Props, Keys extends keyof Props = keyof Props> =
Keys extends keyof Props ? { [K in Keys]: Props[K] } : never;
type Hoge = RequireEither<{
foo: number; bar: string; baz: boolean;
}>;
解説
以下上部コードを理解するにあたり私が知らなかったことです。
- ジェネリクス
- keyof
- Union型
- ConditionalTypes
- MappedTypes
- UnionDistribution
- LookupTypes
- IntersectionTypes
上記と共に1つずつ解説します。
ジェネリクス
ジェネリクスとは型違いの場合、どの型でも対応できるように型引数を使い、型の抽象化をする機能です。
Typename<T>
の形のものです。< >
で任意の名前の列を囲うと、それらの名前を型変数として使用できます。
問題のコードでいうとこの部分です。
RequireEither<Props, Keys extends keyof Props = keyof Props>
RequireEitherという型の引数に、Props
と Keys extends keyof Props = keyof Props
という2つの型変数を入れています。
keyof
keyof T
で「type Tのプロパティ名のUnion型」を表現する型を記述できます。
(Union型については後ほど説明します。)
問題のコードでいうとこの部分ですね。
Keys extends keyof Props = keyof Props
Keys
は型引数で
extends keyof Props
は、型引数の制約です。
Props
のプロパティ名のUnion型をextends
で継承しています。
= keyof Props
は、デフォルト型引数です。
ジェネリック型のKeys
は、Props
のプロパティ名のUnion型を継承することになり
Union型に制約されます。
なのでRequireEither
の第二型引数Keys
は
第一型引数のProps
を元にデフォルトの型を計算しそれが使われます。
第二型引数を明示的に渡すこともできます。
その時は、型引数の制約つまりextends keyof Props
を満たしているものだけになります。
(2020/02/10 「型引数の制約とデフォルト型引数を複合させた記述」というご指摘を問題作成者様から頂き追記しました。)
次にUnion型についてです。
Union型
Union型とは、プロパティを複数の型のうちの1つにしたいとき使用します。
T | S
のように複数の型を|
で繋ぐ形です。TまたはSである型
ということですね。
問題のコードのを見ると
type Hoge = RequireEither<{
foo: number; bar: string; baz: boolean;
}>;
RequireEither
には'foo'
,'bar'
,'baz'
の3つの型があります。
Keysは、先ほど説明した通りProps
のプロパティ名のUnion型です。
この問題の場合、Props
のプロパティ名のUnion型は'foo' | 'bar' | 'baz'
という型になります。
fooまたはbarまたはbazである型
ですね。
つまり、keys
= 'foo'
or 'bar'
or 'baz'
ということになります。
type RequireEither<Props, Keys extends keyof Props = keyof Props>
ここまで理解しました次です。
ConditionalTypes
型レベルの条件分岐が可能な型です。
T extends U ? X : Y
の形です。
TがUを継承しているのならX、していないのならY
になります。
この問題でいうとここのコードです。
Keys extends keyof Props ? { [K in Keys]: Props[K] } : never;
keys
がkeyof Props
を継承しているのなら、{ [K in Keys]: Props[K] }
、継承していないのならnever
になります。
ですが{ [K in Keys]: Props[K] }
がまだよくわかりません。
これがわからないと継承できた時どうなるのが正解かわからないのでまずいです。
{ [K in Keys]: Props[K] }
はなんなのか
MappedTypes
{[P in K]: T}
という形の構文をMappedTypesと言います。
P
の型変数にはin
の後ろに置かれたUnion Types
から1つずつ取り出したものが代入されていき、P
というキーに対しての値がT
という型なります。
(2020/02/10 K
に対しての値がT
という型と書いていましたが、適切ではないとご指摘いただき修正しました。)
問題のコードで置き換えると
{ [K in Keys]: Props[K] }
Keys = 'foo' | 'bar' | 'baz'
でしたね。
K
には、Keys
から1つずつ取り出した'foo'
'bar'
'baz'
入ります。
Keysを展開して書くと、{[K in 'foo' | 'bar' | 'baz']: Props[K]}
となり、
MappedTypesを展開すると{ foo: Props['foo'], bar: Props['bar'], baz: Props['baz'] }
になります。
では、Props[K]
とは何を指しているのか?
Lookup Types
Props[K]
のようなT[K]
という形の構文をLookup Typesと言います。ちょうど、オブジェクト型に対してプロパティ名でアクセスするようなものを型レベルにしたようなものです。
{ foo: Props['foo'], bar: Props['bar'], baz: Props['baz'] }
のLookup Typesを展開すると、
{ foo: number, bar: string, baz: boolean }
になります。
しかし、問題にあった型変数を全て展開していった結果、{ foo: number } | { bar: string } | { baz: boolean }
にはならず、元のProps
にになってしまいました。ですが、問題の方は、実際は前者の型になるように推論されます。
それは、特殊な条件下で動作する、以下のルールが使われているからです。
Union Distribution
Conditional Types構文のextendsの左側に書いた型が型変数であり、UnionTypesだった場合に、各要素について分配される性質です。
一言で説明すると、「union型の条件型」が「条件型のunion型」に変換されます。数学等ではこのような挙動をdistribution(分配)といいますから、union distributionというのもそこから来ています。
問題のコードに置き換えると
type RequireEither<Props, Keys extends keyof Props = keyof Props> =
Keys extends keyof Props ? { [K in Keys]: Props[K] } : never;
// ConditionalTypesのextendsの左側に書いた型Keysが型変数でありUnionTypesなので
// UnionDistribution発生、Keysの各要素 'foo', 'bar', 'baz' について条件判定
('foo' extends keyof Props ? {foo: Props['foo']} : never) | ('bar' extends keyof Props ? {bar: Props['bar']} : never) | ('baz' extends keyof Props ? {baz: Props['baz']} : never)
//↓ LookupTypesにより、PropsオブジェクトのKキーに対応する値
('foo' extends keyof Props ? {foo: number} : never) | ('bar' extends keyof Props ? {bar: string} : never) | ('baz' extends keyof Props ? {baz: boolean} : never)
ということになります。
Conditional Types
は、普通1つしか返さないですが、Union Distributionにより3つ全て返します。
(2020/02/10 突然UnionTypesにはならないとの指摘を受け修正しました。)
Intersection Types
ちなみにUnion Typesと対になるT & S & U
のような型をIntersection Types
と言います。
複数の型を&
で繋いで書くと、TでありSでありUでもある
ような型で表すことができます。
問題のコードに置き換えるとここです。
type Hoge = RequireEither<{
foo: number; bar: string; baz: boolean;
}>;
RequireEither={foo: number} & {bar: string} & {baz: boolean}
です。
つまり、Props = {foo: number} & {bar: string} & {baz: boolean}
になります。
つまり、最初のコードはこうです。
type RequireEither<Props, Keys extends keyof Props = keyof Props> =
// RequireEither<foo & bar & baz, foo | bar | baz>
Keys extends keyof Props ? { [K in Keys]: Props[K] } : never;
// Keysがkeyof Propsを継承しているなら{foo: number} | {bar: string} | {baz: boolean}という型を返し、継承していないなら、neverを返しますということです。
// Keys = foo | bar | bazで、keyof Props = foo | bar | bazなのでKeys = keyof Props
// つまりKeysがkeyof Propsを継承しているなら、foo | bar | bazのどれかの型になります。
// 継承されないというのは、コードが正しい限りありえないのでNeverになります。
// RequireEitherの型変数Propsに代入される型
type Hoge = RequireEither<{
foo: number; bar: string; baz: boolean;
}>;
本当に、foo | bar | bazのどれかの型になるのか気になりました。
解答例のif文と返り値
function func(hoge: Hoge) {
let n: number;
let s: string;
let b: boolean;
const { foo = 0, bar = '', baz = false } = hoge;
if ('foo' in hoge) {
// hogeの引数のなかに'foo'がある時
// {foo:number}と断定される
n = hoge.foo; s = hoge.bar; b = hoge.baz;
}
if ('bar' in hoge) {
// hogeの引数のなかに'bar'がある時
// {bar:string}と断定される
n = hoge.foo; s = hoge.bar; b = hoge.baz;
}
if ('baz' in hoge) {
// hogeの引数のなかに'baz'がある時
// {baz:boolean}と断定される
n = hoge.foo; s = hoge.bar; b = hoge.baz;
}
if ('foo' in hoge && 'bar' in hoge) {
// hogeの引数のなかに'foo'と'bar'がある時
// {foo:number, bar:string}と断定されるが
// {foo:number}|{bar:string}|{baz:boolean}のいずれの選択肢にも存在しないので
// hoge は never となる。never からのプロパティアクセスはすべてエラー。
n = hoge.foo; s = hoge.bar; b = hoge.baz;
}
if ('foo' in hoge || 'bar' in hoge) {
// hogeの引数のなかに'foo'または'bar'がある時
// {foo:number}|{bar:string}の2種類に絞り込まれるが、
// 1. hoge.foo は {bar:string} に存在しておらず絞り込みが不十分なのでエラー
// 2. hoge.bar は {foo:number} に存在しておらず絞り込みが不十分なのでエラー
// 3. hoge.baz はいずれにも存在しないのでエラー
n = hoge.foo; s = hoge.bar; b = hoge.baz;
}
}
func({});// foo | bar | bazのどれでもないのでエラー
func({ foo: 42 }); // 'foo' in hogeの場合
func({ bar: 'some string' }); // 'bar' in hoge
func({ baz: true }); // 'baz' in hoge
func({ hogehoge: true }); // foo | bar | bazのどれでもないのでエラー
ということはこれらを呼び出すとこのようにエラーになると思いました。
// {foo:number, bar:string}は{foo:number}|{bar:string}|{baz:boolean}のいずれの選択肢にも存在しないのでneverとなり
// if ('foo' in hoge && 'bar' in hoge)このif文条件から外れ
// if ('foo' in hoge || 'bar' in hoge)このif文条件からも外れエラーになる
func({ foo: 42, bar: "string" });
// 通る条件がないのでエラーになる
func({ foo: 42, baz: true });
// 通る条件がないのでエラーになる
func({ bar: "star", baz: true });
// {foo:number, bar:string, baz: noolean}は{foo:number}|{bar:string}|{baz:boolean}のいずれの選択肢にも存在しないのでneverとなり
// if ('foo' in hoge && 'bar' in hoge)このif文条件から外れ
// if ('foo' in hoge || 'bar' in hoge)このif文条件からも外れエラーになる
func({ foo: 42, bar: "star", baz: true });
なんと、エラーになりませんでした。
なぜエラーにならないのか?
めっちゃ不思議でした。
いろんな人に聞いた結果一番納得のいった答えがこれでした。
第一Generics は何も制約がかかっていないので、何でも含むことができます。
利用時 either が働かないのはこのため。
Twitter|@takepepe
つまり第1ジェネリクスであるProps
に型を1つしか受け取らない制約がないからです。
その制約はつまり、 Intersection Types
で受け取ったRequireEither
をUnion Types
でに変換し絞り込むということです。
その方法がないか一緒に考えてくれた方とめっちゃ調べました。
このページでそのことについて言及されていたので載せます。
I've seen a lot of unionToIntersection but can you the reverse?
{ name: string } & { age: number } to { name: string } | { age: number }?
Intersection Types
からUnion Types
に変換することは可能ですか?と聞いてます。
それに対しての返答です。
You can't. It only works in one direction because TS distributes over unions, but not over intersections
You can't.なのでできません。
TSではUnion Types
を介しての分割できるが、Intersection Types
を介しての分割はできない。という回答です。
一緒に考えてくれた方が、問題作成者の方に
- エラーを期待したがエラーにならなかったこと
- TSでは
Union Types
を介しての分割できるが、Intersection Types
を介しての分割はできない
こと
お伝えしてくれました。その時の返信がこちらです。
ご指摘の通り、foo, bar両方のキーを持つオブジェクトをfuncの引数に渡してもエラーになりません。
“either”とするには都合が悪いため、問題のコードのfunc使用箇所ではあえてその性質を伏せています。
つまり、完全なEither
は実現できないということです。
画像の性質は完全なるEither
ではなかったということもわかりました。
参考にさせていただいた記事
-
ジェネリクス
-
keyof
-
UnionTypes
-
ConditionalTypes
-
MappedTypes
- TypeScript2.1.4で導入されたkeyofキーワードとinキーワード、そしてLookup TypesとMapped Types
- [TypeScript 2.1のkeyofとかMapped typesがアツい#MappedTypes] (https://qiita.com/Quramy/items/e27a7756170d06bef22a#mapped-types)
- TypeScriptの型入門#MappedTypes
- TypeScript#MappedTypes
-
in
-
UnionDistribution
-
LookupTypes
-
IntersectionTypes
SpcialThanks:りょー,mpyw,Takepepe
この記事書いてて頭が何度もおかしくなりましたが、色々なことが学べて楽しかったです!
多分、とても至らない部分あると思うので編集依頼などで教えていただければ幸いですヽ(;▽;)ノ
ここまで、読んでいただきありがとうございました。
この解答例の最終的解説付き
Takepepeさんによる別途回答(複数の型をerrorにしてくれています)
(修正5回ほどしました)