別にしんどくないブログ

技術のことや読書メモを書いています

TypeScriptの便利な型コレクションtype-festと型パズル解説~前編~

TypeScript Advent Calendar 2019 - Qiita 14日目の記事です。

type-festというTypeScriptの便利な型を集めたnpmパッケージがあります。
今回はtype-festの中から特に複雑なUtilitiesの型の紹介とそれらの型パズルのような型定義について解説したいと思います。
この記事がMapped TypesやConditional Typesを使った複雑な型パズルの理解への一助になれば幸いです。

github.com

長いので前後半に分けました。後半はまた後日公開します。

前提知識

今回紹介するtype-festの型定義を読むうえで必須の知識があります。それがMapped TypesConditional Typesです。
それらを使ったUtility Typesについても知っておいた方がいいです。
まずはそれらを紹介します。もし既知の場合は「前提知識」の章は読み飛ばしてください。

Utility Types

TypeScriptはstringnumberなどの単純なプリミティブな型だけでなく、便利な型がいくつか組み込まれています。
それらをUtility Typesといいます。

www.typescriptlang.org

これらの型は大変便利です。例えば以下のようなUser型があったとします。

interface User {
  id: number;
  name: string;
  email: string;
  birthday: Date;
}

Mapped Typesを利用したUtility Types

Userからidnameだけ持つ型を再定義したいときPickという型を使うことで簡単に型定義することが可能です。

type MinimalUser = Pick<User, "id"|"name">;

このMinimalUser型は以下と同じ型になります。

type MinimalUser = {
  id: number;
  name: string;
}

PickのGenericsの第1引数から第2引数に指定したキーのみ持った型を定義することができます。

これはMapped Typesという仕組みを使っています。

type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};

Mapped Typesについて説明をすると長くなるので割愛しますが、以下の記事が詳しいです。

qiita.com

Conditional Typesを利用したUtility Types

Conditional Typesを使ったUtility Typesも存在します。

ExcludeはGenericsの第1引数から第2引数を除去します。

type UserKeys = keyof User;
// "id"|"name"|"email"|"birthday"

type ExcludedKeys = Exclude<UserKeys, "email" | "birthday">;

このExcludedKeysはUserKeysから"email""birthday"を取り除いた型になります。
以下と同じ型になります。

type ExcludedKeys = "id"|"name";

ではExcludeはどのように型定義されているのか見てみます。

type Exclude<T, U> = T extends U ? never : T;

三項演算子のようなもので定義されています。これはT extends Utrueの場合neverを返し、falseの場合Tを返します。
このTUに先程の値を適用すると以下のようになります。

("id"|"name"|"email"|"birthday") extends "email"|"birthday" ? never : ("id"|"name"|"email"|"birthday")

()内は順番に値を取り出すため、各値のみ考えると以下のようになります。
もう少しプログラムっぽくと以下のようになります。
※実際は動作しない擬似コードです。

// "id"|"name"|"email"|"birthday"を一つずつループする
for key of ("id"|"name"|"email"|"birthday") {
  if (key extends ("email"|"birthday")) {
    // keyが"email"または"birthday"の場合はneverを返す
    return never;
  } else {
    return key
  }
}
// idの場合
"id" extends "email"|"birthday" ? never : "id"

"id" extends "email"|"birthday"はfalseになるので"id"を返します。

// emailの場合
"email" extends "email"|"birthday" ? never : "email"

"email" extends "email"|"birthday"はtrueになるのでneverを返します。

never発生し得ない値を表す型です。そのため、Union Types(|で列挙した型)からは除去されます。

他にもUtility Typesは便利な型定義を提供しています。
以前勉強会でUtility Typesのいくつかの使い方や型定義について発表したので興味がございましたら発表資料も読んでいただけると幸いです。

speakerdeck.com

type-fest

本題のtype-festですが、これまで説明してきたTypeScript本体に組み込まれているUtility Typesにはまだ無いが実用的な便利な型コレクションライブラリになっています。
作者はおなじみのSindre Sorhus氏です。

以下のようにnpm installして使うことができます。

$ npm install type-fest

今回はtype-festが提供している型の中から特に複雑な型定義を行っているUtilitiesの型を紹介します。

Except

Exceptを使うことでObjectの型定義から指定のプロパティを除去することができます。

import {Except} from 'type-fest';
type Foo = {
  a: number;
  b: string;
  c: boolean;
};
type FooWithoutA = Except<Foo, 'a' | 'c'>;
//=> {b: string};

型定義は以下のようになっています。前述のPickExcludeが使われています。

type Except<ObjectType, KeysType extends keyof ObjectType>
  = Pick<ObjectType, Exclude<keyof ObjectType, KeysType>>;

一つずつ解説します。

Exclude<keyof ObjectType, KeysType>は値を代入すると以下のようになります。

Exclude<keyof Foo, "a"|"c">
//=> "b"

この結果を踏まえてPickに値を代入すると以下のようになります。
Pickは第1引数のObjectから第2引数で指定したキーのObjectの型を定義します。

Pick<User, "b">
//=> {b: string};

Mutable

MutableはObjectの型のreadonlyなプロパティをすべてreadonlyでは無いプロパティに変更して型を再定義します。

import {Mutable} from 'type-fest';
type Foo = {
    readonly a: number;
    readonly b: string;
};
const mutableFoo: Mutable<Foo> = {a: 1, b: '2'};
//=> {a: number; b: string;}

mutableFoo.a = 3; // 代入可能

型定義は以下のとおりです。

type Mutable<ObjectType> = {
  -readonly [KeyType in keyof ObjectType]: ObjectType[KeyType];
};

-readonly-予約語を無効にします。
-?であれば?が無効になりOptionalなプロパティではなくなります。Utility Typesの一つRequired-?を使った型定義により全てのプロパティを必須項目にする効果があります。

Merge

Mergeは型をマージします。

import {Merge} from 'type-fest';
type Foo = {
  a: number;
  b: string;
  c: Symbol;
};
type Bar = {
  b: number;
  d: boolean;
};
const foobar1: Merge<Foo, Bar> = {a: 1, b: 2, c: Symbol(1), d: true};
/**
{
  a: number;
  b: number;
  c: Symbol;
  d: boolean;
}
 */

型定義は以下のとおりです。

type Merge<FirstType, SecondType>
  = Except<FirstType, Extract<keyof FirstType, keyof SecondType>> & SecondType;

前述したExceptとUtility TypesのExtractを利用しています。
Extract<T, U>TからUを抽出することができます。

type T0 = Extract<"a" | "b" | "c", "a" | "f">;  // "a"
type T1 = Extract<string | number | (() => void), Function>;  // () => void

ではMergeの型定義について一つずつ解説します。 Extract<keyof FirstType, keyof SecondType>の部分にFoo型とBar型を入れると以下のようになります。

type T0 = Extract<keyof Foo, keyof Bar>
const duplicateKey: T0 = "b"

FooとBarの両方が持つObjectのキーの値を抽出しています。なのでT0型の変数には"b"という値のみ代入可能です。

次にExcept<FirstType, Extract<keyof FirstType, keyof SecondType>>の部分にさFoo型と先程のT0型を入れてみます。

type T1 = Except<Foo, T0>
const obj1: T1 = {
  a: 1,
  c: Symbol(1)
};

Foo型からT0型を除外したObject型がT1に入ります。 Foo型はa``b``cというプロパティを持っています。T0"b"という値のみ持つことができる型です。
keyof Foo - T0 = "a" | "c"ということになります。そのためT1にはacのプロパティのみ残ります。

Merge型と同じことをT1型を使って定義すると以下のようになります。

type FooBar = T1 & Bar;
//=> Merge<Foo, Bar>
const foobar2: FooBar = { a: 1, b: 2, c: Symbol(1), d: true }

&Intersection Typesというもので型の結合を行ってくれます。
T1{a: number; c: Symbol;}Bar{b: number; d: boolean;}の型定義がされています。この2つを組み合わせることで、Merge<Foo, Bar>と同じことができます。

今までみてきたようにMerge<A, B>はABに同じプロパティがあった場合、Bの型が優先されます。

MergeExclusive

MergeExclusive<A, B>とした場合、ABのObject型の相互のキーを排他的に持つことができます。

import { MergeExclusive } from 'type-fest';

type Foo = {
  a: number;
  b: string;
};
type Bar = {
  b: number;
  c: boolean;
};

const foobar1: MergeExclusive<Foo, Bar> = {
  a: 1,
  b: "2"
}
// OK

const foobar2: MergeExclusive<Foo, Bar> = {
  b: 1,
  c: true
}
// OK

const foobar3: MergeExclusive<Foo, Bar> = {
  a: 1,
  b: 2,
  c: true
}
// Error

MergeExclusive<Foo, Bar>の変数はFoo型とBar型のどちらの型でも適用可能ですが、どちらかの型しか適用できません。

型定義はこちらです。

type MergeExclusive<FirstType, SecondType> =
    (FirstType | SecondType) extends object ?
        (Without<FirstType, SecondType> & SecondType) | (Without<SecondType, FirstType> & FirstType) :
        FirstType | SecondType;

MergeExclusiveの前段にWithoutという型が定義されているので、そちらから先に解説します。

type Without<FirstType, SecondType> = {[KeyType in Exclude<keyof FirstType, keyof SecondType>]?: never};

FooとBarを適用したWithout<Foo, Bar>を分解すると以下のようになります。

type FooKeys = keyof Foo; // "a"|"b"
type BarKeys = keyof Bar; // "b" | "c"
type ExcludeKeys = Exclude<FooKeys, BarKeys>; // "a"
type WithoutFooBar = {[K in ExcludeKeys]?: never}; // {a?: undefined}

上記からわかるようにWithoutはFooにしかないプロパティにneverを適用しています。
Without<Bar, Foo>とすると{c?: undefined}の型になります。

では次にMergeExclusiveの中で使われているWithout<FirstType, SecondType> & SecondTypeの部分にFooとBarを適用してみると、

type WithoutFooBar = Without<Foo, Bar>; // {a?: undefined}
type MergedFooBar = WithoutFooBar & Bar; // {b: number; c: boolean;}

Without<SecondType, FirstType> & SecondTypeにFooとBarを適用すると以下のようになります。

type WithoutBarFoo = Without<Bar, Foo>; // {c?: undefined}
type MergedBarFoo = WithoutBarFoo & Foo; // {a: number; b: string;}

(Without<FirstType, SecondType> & SecondType) | (Without<SecondType, FirstType> & FirstType)を上記の結果を踏まえた形に置き換えると以下のようになります。

{b: number; c: boolean;} | {a: number; b: string;}

結果的にFooかBarがUnion Typesとして定義されることになり、どちらの型も適用できるがどちらか一方のみしか適用できないようになります。

Conditional Typesの条件(FirstType | SecondType) extends objectはFirstTypeまたはSecondTypeがobjectかどうか判定しているだけです。
FooとBarの場合は{b: number; c: boolean;}または{a: number; b: string;}のどちらかの型を適用することができます。

MergeExclusive<number, {a: number}>とした場合は以下のようになります。

type PrimitiveOrObject = MergeExclusive<number, { a: number }>; // number | {a: number}
const a: PrimitiveOrObject = 1; // OK
const obj: PrimitiveOrObject = { a: 1 }; // OK

RequireAtLeastOne

RequireAtLeastOneを使うと指定した複数のキーから少なくとも1つは必須な項目にすることができます。

import { RequireAtLeastOne } from 'type-fest';

type Foo = {
  a?: number;
  b?: string;
  c: boolean
};

const foo1: RequireAtLeastOne<Foo, "a" | "b"> = { a: 1, c: true } // OK
const foo2: RequireAtLeastOne<Foo, "a" | "b"> = { b: "2", c: true } // OK
const foo3: RequireAtLeastOne<Foo, "a" | "b"> = {c: true } // Error
const foo4: RequireAtLeastOne<Foo> = {a: 1, b: "2", c: true } // OK
const foo5: RequireAtLeastOne<Foo> = {a: 1, c: true } // OK

型定義は以下の通りです。

type RequireAtLeastOne<ObjectType, KeysType extends keyof ObjectType = keyof ObjectType> =
  {
     [Key in KeysType]: (
       Required<Pick<ObjectType, Key>>
     )
  }[KeysType]
  & Except<ObjectType, KeysType>;

まずは<ObjectType, KeysType extends keyof ObjectType = keyof ObjectType>から解説します。 KeysType extends keyof ObjectType = keyof ObjectTypeにFooを適用すると以下のようになります。

KeysType extends keyof Foo = keyof Foo

KeysType extends keyof FooはFoo型が持つキー値"a"``"b"``"c"のうちのいくつかを指定することを可能にしています。
= keyof Fooの部分はもし指定が無かった場合はkeyof Fooの値を適用する。つまり"a" | "b" | "c"となります。

そのため、RequireAtLeastOne<Foo>RequireAtLeastOne<Foo, "a" | "b" | "c">と同じです。

次にRequired<Pick<ObjectType, Key>>の部分を解説します。 Utility TypesからRequiredPickが使われています。
RequiredはObject型のプロパティすべてを必須項目にします。Pickは前述の通りです。

Fooを適用し分解すると以下のようになります。

type Picked = Pick<Foo, "a">;  //  { a?: number | undefined; }
type Req = Required<Picked> // { a: number }

次に以下のKeysTypeのMapped Typesの部分を解説します。

type   {
  [Key in KeysType]: (
    Required<Pick<ObjectType, Key>>
  )
}[KeysType]

これにFooを適用して分解すると以下のようになります。

type KeysType = keyof Foo; // "a" | "b" | "c"
type RequiredFoo =
  {
    [Key in KeysType]: (
      Required<Pick<Foo, Key>>
    )
  }[KeysType]
//=> Required<Pick<Foo, "a">> | Required<Pick<Foo, "b">> | Required<Pick<Foo, "c">>
//=> {a: number} | {b: string} | {c: boolean}
const a: RequiredFoo = { a: 1, b: "2", c: true }; // OK
const a: RequiredFoo = { a: 1 }; // OK

Except<ObjectType, KeysType>の部分は前述のExceptを使ってKeysTypeの項目を除外しています。

type ExceptFoo = Except<Foo, "a"> // { b: string; c: boolean; }
type ExceptFoo = Except<Foo, "a" | "b"> // { c: boolean; }

最後にRequireAtLeastOne<Foo, "a" | "b">のときのFooと"a"|"b"をRequireAtLeastOneの型定義に適用して分解してみます。

type RequiredFoo =
  {
    [Key in KeysType]: (
      Required<Pick<Foo, Key>>
    )
  }[KeysType]
//=> {a: number} | {b: string}

type ExceptFoo = Except<Foo, "a" | "b">
//=> { c: boolean; }

type RequireAtLeastOneFoo = RequiredFoo & ExceptFoo;
//=> ({a: number} | {b: string}) & { c: boolean; }
//=> { a: number; c: boolean } | {b: string; c: boolean; }

const foo1: RequireAtLeastOne<Foo, "a" | "b"> = { a: 1, c: true } // OK
const foo2: RequireAtLeastOne<Foo, "a" | "b"> = { b: "2", c: true } // OK

まとめ

type-festは便利ですが、その型定義は複雑な型パズルになっていることがわかりました。
それらを読み解くことがMapped TypesやConditional Typesの理解を深めることができるのではないかと思いこのエントリを書きました。
もし複雑な型定義が必要になったときは参考にしてみてください。また、作った複雑な型定義は誰かが必要としているかもしれませんので、OSSとして公開したりtype-festにPRを送ったりTypeScript本体にPRを送ってみていただけると嬉しいです。

とても長くなったので、続きは後半として近日公開します。

最後までお読みいただきありがとうございました。不備や質問はTwitter - @shisama_宛かブコメでお願いいたします。