LoginSignup
62

More than 3 years have passed since last update.

TypeScriptの型パズル初級問題の解説

Last updated at Posted at 2020-02-08

TypeScriptの型パズル初級の解答例を見て、なぜこのコードでこのエラーなのか考えていたら
私にはとてもいろんなことが学びになったので、共有します。
それではまず、TypeScriptの型パズル初級の問題を見てください。

TypeScriptの型パズル初級問題の内容

画像の性質を持つ型 RequireEither を作ってください。(TypeScript v3.7.5)
スクリーンショット 2020-02-07 15.26.52.png

こちらはりょーさんが作成した問題です。(掲載許可は事前にとりました)

問題を解くためには、まずどんな性質を持つ型を作ればいいのか理解していないと無理です。

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という型の引数に、PropsKeys 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;

keyskeyof 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というのもそこから来ています。

TypeScript型初級#conditionaltypeにおける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 });

気になる実行結果がこちらです。
スクリーンショット 2020-02-08 12.22.30.png

なんと、エラーになりませんでした。

なぜエラーにならないのか?

めっちゃ不思議でした。
いろんな人に聞いた結果一番納得のいった答えがこれでした。

第一Generics は何も制約がかかっていないので、何でも含むことができます。
利用時 either が働かないのはこのため。
Twitter|@takepepe

つまり第1ジェネリクスであるPropsに型を1つしか受け取らない制約がないからです。
その制約はつまり、 Intersection Typesで受け取ったRequireEitherUnion Typesでに変換し絞り込むということです。

その方法がないか一緒に考えてくれた方とめっちゃ調べました。
このページでそのことについて言及されていたので載せます。
スクリーンショット 2020-02-08 12.57.04.png

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ではなかったということもわかりました。

参考にさせていただいた記事

SpcialThanks:りょー,mpyw,Takepepe

この記事書いてて頭が何度もおかしくなりましたが、色々なことが学べて楽しかったです!
多分、とても至らない部分あると思うので編集依頼などで教えていただければ幸いですヽ(;▽;)ノ
ここまで、読んでいただきありがとうございました。

この解答例の最終的解説付き
Takepepeさんによる別途回答(複数の型をerrorにしてくれています)

(修正5回ほどしました)

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
62