Help us understand the problem. What is going on with this article?

普段は Union 型が返るけど、文字列で種類を渡したら具体的な型で返ってくる関数を作る

※ 本記事の内容は最終的に as で回避している箇所があります。たぶんどうしようもないと思いますが、より良い方法をご存知の方、思いつく方は是非コメントください(無理だと思う、というコメントも歓迎します)。

やりたいこと

TypeScript で以下のような判別共用体を使うと思います。

interface Hoge {
    type: "Hoge";
    foo: string;
}

interface Fuga {
    type: "Fuga";
    bar: string;
}

type Piyo = Hoge | Fuga;

これに対して、引数で "Hoge""Fuga" を渡すとそれぞれのインスタンスを返してくれ、引数を省略する(undefined を渡す)と HogeFuga のどちらかを返すような関数があったとします。この関数の戻り値を単純に Piyo 型(つまり Hoge | Fuga 型)とすると、いささか不便です。

// Piyo["type"] は "Hoge" | "Fuga" 型と同義
type PiyoType = Piyo["type"];
function getPiyo(type?: PiyoType): Hoge | Fuga {
    // (実装略)
}

const a = getPiyo("Hoge"); // かならず Hoge が返る
console.log(a.foo);
              ~~~ Property 'foo' does not exist on type 'Piyo'. Property 'foo' does not exist on type 'Fuga'.

const b = getPiyo("Fuga"); // かならず Fuga が返る
console.log(b.bar);
              ~~~ Property 'bar' does not exist on type 'Piyo'. Property 'bar' does not exist on type 'Hoge'.

const c = getPiyo(); // Hoge か Fuga のどちらかが返る
if (c.type === "Hoge") { // なので当然、型ガードが必要
    console.log(c.foo); // 分岐した中なので呼べる。TypeScript コンパイラ賢い
} else {
    console.log(c.bar);
}

イメージとしては、document.querySelectordocument.querySelector(".foo") とすると Element が返るけど、document.querySelector("button") とすると HTMLButtonElement が返る1のと似たようなことがやりたいです。

すなわち、先ほどのエラーの箇所が if による分岐なく HogeFuga として、キャスト無しでプロパティアクセス出来てほしい。どうやるの?というのが当記事の内容です。

ちなみに、以下の条件で実現したいです。

  • 今回はわけあって2オーバーロードを使わない。
  • Hoge や Fuga の名前を直接随所に書きたくない。Piyo を使って定義したい(後から増えていくため)。

やり方

結果的に以下のやり方を思いつきました。

// T には extends PiyoType 制約をつけてもよいかも
type PiyoOf<T, P extends Piyo = Piyo> = P extends { type: T } ? P : never;
type PiyoTypeMap = { [T in PiyoType]: PiyoOf<T> };

function <T extends PiyoType>(type?: T):
        T extends undefined ? Piyo : PiyoTypeMap[T] {
    // (実装略)
    return piyo as T extends undefined ? Piyo : PiyoTypeMap[T]; // ← ここで as しないと互換性がないと怒られる
}

※ ちなみに as は外せなくて、中で if (type === undefined) などで分岐してそのパスで return しても整合性が取れてるとは考えてくれないようです。as を使うと {} なども返せてしまいましたが、nullundefined0 や "" などは弾いてくれたので as any よりは幾分マシかと3

これをもうちょっと省略すると

function <T extends PiyoType, R extends Piyo>(type?: T):
        T extends undefined ? Piyo : (R extends { type: T } ? R : never)

が、投稿後に参考記事で挙げている演習にそっくりであることに気づいて、以下がベストという結論になりました。「これ型演習でやったところだ!」ってなるのが遅い。。。

function getPiyo<T extends PiyoType>(type?: T):
        undefined extends T ? Piyo : Extract<Piyo, { type: T }>
}

Playground で試す

document.querySelector を参考に PiyoMap を作るやり方

lib.dom.d.ts を見てみると document.querySelector は以下のようになっています。

querySelector<K extends keyof HTMLElementTagNameMap>(selectors: K): HTMLElementTagNameMap[K] | null;

ほうほう、HTMLElementTagNameMap とな…?見てみます。

interface HTMLElementTagNameMap {
    "a": HTMLAnchorElement;
    "abbr": HTMLElement;
    "address": HTMLElement;
    "applet": HTMLAppletElement;
    ...
}

あー、なるほど…

つまり、与えられた文字列から返す型へマップする辞書型を定義しておいて、それを Map[K] として型変数をキーに戻り値の型を取り出すと。

ということで、先の Piyo および PiyoType から以下のような型を作ることを考えてみます。

type PiyoMap = {
    "Hoge": Hoge,
    "Fuga": Fuga
}

いま、"Hoge" | "Fuga" は手元にあるので、Mapped type が使えそうです。

type PiyoMap = { [T in PiyoType]: ...(ここに T  "Hoge" なら Hoge を、"Fuga" なら Fuga をもってきたい)... }

なら という単語から Conditional type が使えそうとにらみます。上記コメントでは具体的な言い方をしましたが、やりたいことをより一般化すると「{ type: T } であるような Piyo の要素にしたい」です。

Conditional type での条件といえば extends によるものですから、Piyo の中から { type: T } であるようなものだけ残せばいいと考えます。Piyo が Union type であることを思い出しましょう。

Union type からある条件を満たすものだけ残して他がなくなったような型を作るには Union distribution を利用します。

(投稿後追記): 以下の PiyoOf<K, P> は標準型の Extract<T, U> を使って Extract<Piyo, { type: T }> と書けそうでした

PiyoOf<K, P extends Piyo> = P extends { type: K } ? P : never

Union distribution を発生させるために Piyo を型変数に入れます。P extends Piyo という制約がある型変数ですが、実は Union distribution を発生させたいだけなので Piyo しか指定されません。

試しに K = "Hoge" として考えてみます。

PiyoOf<"Hoge", Piyo>

となった場合

P(= Piyo = Hoge | Fuga) extends { type: "Hoge" } ? P : never

となります(括弧書きは私のイメージです)。

Pextends の前に単独で存在する型変数で、かつ PPiyo つまり Union 型が指定されたため、 Union distribution が起こります。

(Hoge extends { type: "Hoge" } ? Hoge : never) | (Fuga extends { type: "Hoge" } ? Fuga : never)

ここで左は条件を満たし、右は条件を満たさないので

Hoge | never

となり、「Union における never は無いものとして振る舞う」ため

Hoge

となります。

したがって

type PiyoOf<T, P extends Piyo = Piyo> = P extends { type: T } ? P : never;
type PiyoTypeMap = { [T in PiyoType]: PiyoOf<T> };

PiyoTypeMap

type PiyoTypeMap = {
    "Hoge": PiyoOf<"Hoge">,
    "Fuga": PiyoOf<"Fuga">
}

となり、先ほどの説明により

type PiyoTypeMap = {
    "Hoge": Hoge,
    "Fuga": Fuga
}

となります。

あとは目的の関数で引数が undefined かどうかで分岐して PiyoTypeMap を使えば OK です。

type PiyoOf<T, P extends Piyo = Piyo> = P extends { type: T } ? P : never;
type PiyoTypeMap = { [T in PiyoType]: PiyoOf<T> };

return function <T extends PiyoType>(type?: T):
    // T つまり引数の型が undefined なら Piyo(=Hoge|Fuga)、そうじゃなければ PiyoTypeMap[T] つまり T に応じた具象型
        T extends undefined ? Piyo : PiyoTypeMap[T] {
    // (実装略)
    return piyo as any; // ← ここで as any しないと互換性がないと怒られる
}

PiyoTypeMap を介さずに直接 Union distribution を使うやり方

さらに省略した方は何をやっていたでしょうか?

function <T extends PiyoType, R extends Piyo>(type?: T):
    T extends undefined ? Piyo : (R extends { type: T } ? R : never);

ここで

T extends undefined ? Piyo : 

はまぁいいですね。引数が undefined なら Union 型である Piyo で返る(=Hoge | Fuga という未確定状態)ということです。

else 節の方は何かというと

(R extends { type: T } ? R : never)

R は型変数ですが推論に任せる想定です。

この関数を呼ぶ側としては

getPiyo("Hoge");

のように呼ぶので、T"Hoge" などに推論されます。

そして RHoge または Fuga なわけなので Hoge | Fuga として条件判定が入ります(たぶん…ここら辺あやしい4)。すると先ほどと同じく Union distribution によって (R extends { type: T } ? R : never) は条件型の Union 型に分配されます。

T"Hoge" と推論されてますから

(Hoge extends { type: "Hoge" } ? Hoge : never) | (Fuga extends { type: "Fuga" } ? Fuga : never)

と先ほど見たような形になり、最終的に RHoge に落ち着く、となります。

そもそも演習でやったところだ

やってる最中に若干のデジャヴを感じつつも気づかなかったのですが、TypeScriptの型演習 4-6にそっくりでした。それによると Extract<T, U> がまさに欲しかったもので、自前で定義した PiyoOf<T, P> とほぼ一緒です。

また、T extends undefined より undefined extends T の方が良かったと思う(Union distribution に気を使わなくていいから?継承関係的に?今回はどっちでもいい気もする?)ので、そこも直すと以下のようにできます。

function getPiyo<T extends PiyoType>(type?: T):
    undefined extends T ? Piyo : Extract<Piyo, { type: T }>

非常にシンプルですね。

感想

最後に感想として、X extends Y ? X : never という形は常套句(イディオム)っぽいなぁと感じました。

Union 型を型世界でいう配列として捉えたとき、実行コードでいう .filter(x => x === y) に近い意味合い(実際は extends なので x is Y みたいな?)と考えるとイメージしやすいかと思います。もにゃどでいうと .flatMap(x => x === y ? [x] : []) ってことかと。

解説は以上となります。

参考記事

当記事を書くまでの理解度に到達するにあたって、下記記事には大変お世話になりました。こちらでお礼を述べさせていただきます。


  1. 実際にはセレクタにマッチしないケースがあるので | null になりますが本題ではないのでおいときます 

  2. クロージャーとして返す関数で実現したかったからなのですが、もしかして return function(...){ ... } で返す関数に引数違いのオーバーロード定義できたりしますかね… 

  3. プリミティブ型が弾かれるのに {} はキャストできてしまう理屈がよくわからない…オブジェクト型かプリミティブ型かのレベルの判定だけ有効ってこと…? 

  4. Conditional type の結果部は条件部を判定するまで遅延評価されます。最終的に RHoge に確定するのですが、その前に条件を評価する必要があり、その時点ではまだ RPiyo であることまでしか推論できないので Union distribution が発生します。という理屈だと思います。。。 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away