TypeScriptでMapped Typesを使ってきれいなインターフェースを作る話

みなさんこんにちは。この記事は[TypeScript Advent Calendar 2020](https://qiita.com/advent-calendar/2020/typescript)の5日目の記事です。

TypeScriptには**intersection type**という機能があります。これは`T & U`のような構文をもつ型であり、意味としては「`T`でもあり`U`でもある型」です。

## 構造的部分型とIntersection Type

「`T`でもあり`U`でもある」という説明の仕方をされるとIntersection Typeが何の役に立つのかピンと来ないという方がいるかもしれません。実際のところ、Intersection Typeは**オブジェクト型を合体する**という役割によく使われます。

例えば、`T`が`{ foo: string }`型で`U`が`{ bar: number }`型だった場合、`T & U`は実質上`{ foo: string; bar: number }`型となります。

```ts
type T = { foo: string };
type U = { bar: number };

type I = T & U;
// 代入できる
const obj: I = {
  foo: "foo",
  bar: 123
};
```

見方によっては、`T & U`は`T`と`U`の両方のプロパティを併せ持つオブジェクト型であると言えます。実際のところ、このような直観でIntersection型を扱うことが多くあります。

このようになる理由には**構造的部分型**が関わっています。そもそも、`T`（`{ foo: string }`）型とは「`string`型のプロパティ`foo`を持つオブジェクト」という意味の型でした。構造的部分型の世界では、この型は`foo`以外のプロパティには関与しません。つまり、この型の意味は「`string`型のプロパティ`foo`を持っていれば他のプロパティは何でもいい」という意味になります。`{ foo: "foo", bar: 123 }`というオブジェクトもこの条件を満たしているので`T`型に当てはまります。同様に、`U`も「`number`型のプロパティ`bar`を持つオブジェクト」という意味です。

`T & U`は`T`と`U`の両方の条件を満たす値の型ですから、具体的な条件は「`string`型のプロパティ`foo`を持ち、`number`型のプロパティ`bar`も持つ」となります。`{ foo: "foo", bar: 123 }`は確かにこれに当てはまりますね。

## Intersection Typeとジェネリクス

上のような例はあまり面白くありませんが、ジェネリクスと組み合わせるとすこし意味のある例が作れます。次の例では、イミュータブルなレコード（`ImmutableRecord<Data>`）を扱う`empty`関数と`set`関数を定義しました（`declare function`で型定義だけ書き、実装は省略しています）。

```ts
declare const recordBrand: unique symbol;

type ImmutableRecord<Data> = {
    [recordBrand]: Data
};

/** 新しい空のImmutableRecordを返す */
declare function empty(): ImmutableRecord<{}>;

/**
 * 受け取ったImmutableRecordに新しいキーを足してできた
 * 新しいImmutableRecordを返す
 */
declare function set<Data, Key extends string, Value>(
    map: ImmutableRecord<Data>,
    key: Key,
    value: Value,
): ImmutableRecord<Data & {
    [K in Key]: Value
}>;

/**
 * 受け取ったImmutableRecordから指定されたキーの値を返す
 */
declare function get<Data, Key extends keyof Data>(
    map: ImmutableRecord<Data>,
    key: Key
): Data[Key];


// ImmutableRecord<{}>
const m1 = empty();
// ImmutableRecord<{ foo: string }>
const m2 = set(m1, "foo", "pikachu");
// ImmutableRecord<{ foo: string } & { bar: number }>
const m3 = set(m2, "bar", 12345);

// string
const v1 = get(m3, "foo");
// number
const v2 = get(m3, "bar");
```

`empty`関数は空のレコード（`ImmutableRecord<{}>`型）を作って返します。`set`関数は今のレコードと新しいデータを受け取って、データが追加された新しい`ImmutableRecord`を返します。`get`はレコードから指定されたキーのデータを取り出します。

ここで、`set`関数の返り値の型にIntersection型が使われています。`Data & { [K in Key]: Value }`というのは、`Data`と`{ [K in Key]: Value }`を合体させたオブジェクト型であると見ることができます。`&`の右にあるのはmapped typeであり、ここでは`Value`を値とする`Key`という名前のキーを一つだけ持つオブジェクト型を表します[^note_mapped_type]。

[^note_mapped_type]: 正確には、`Key`がユニオン型のときは1つとは限りません。実はそれだと今回のコードの安全性が壊れてしまうので、この記事では省略していますがユニオン型に対する対策が必要です。

上の例では`m1`は`ImmutableRecord<{}>`であり、次の`m2`は文字列の値を持つ`"foo"`というキーが追加されたので、`ImmutableRecord<{ foo: string }>`となります。これにより、`m2`は`foo`というキーを持つことが示されます。

さらに、`m3`は`"bar"`も追加されて`ImmutableRecord<{ foo: string } & { bar: number }>`となりました。このように、intersection型を用いることでデータの増加を表すことができます。

## 上書きの問題と対処法

上のような実装は比較的単純なもので、そのため大きな問題点があります。それは、**データの上書きを表現できない**ということです。上の実装では、次のように同じキーに対して2回`set`をしたときに前の型と新しい型が混ざってしまいます。

```ts
// ImmutableRecord<{}>
const m1 = empty();
// ImmutableRecord<{ foo: string }>
const m2 = set(m1, "foo", "pikachu");
// ImmutableRecord<{ foo: string } & { foo: number }>
const m3 = set(m2, "foo", 12345);

// never
const v1 = get(m3, "foo");
```

この例では`m3`が`ImmutableRecord<{ foo: string } & { foo: number }>`となっていますが、この中のインターセクション型は`foo`が`string`型でもあり`number`型でもある、すなわち`string & number`型であるという意味になっています。文字列であり数値でもある値は存在しませんから、これは存在しないことを表す`never`型に解決されます。よって、上の例の`v1`の型が`never`となります。

これは求める結果ではありませんね。正しくは、2回目の`set`の時点で1回目の`foo`の型は捨てられるべきです。これを実現するためには、まず`Data`から`foo`を消したあとに新しい型を追加する必要があります。これはこのように実装できます。

```ts
/**
 * 受け取ったImmutableRecordに新しいキーを足してできた
 * 新しいImmutableRecordを返す
 */
declare function set<Data, Key extends string, Value>(
    map: ImmutableRecord<Data>,
    key: Key,
    value: Value,
): ImmutableRecord<Omit<Data, Key> & {
    [K in Key]: Value
}>;
```

変更点は、返り値の`&`の左の`Data`が`Omit<Data, Key>`に変わったことです。これは、`Data`から`Key`という名前のキーを消した型という意味です。`Omit`は標準ライブラリに備わっています。これならば直感通りに動作し、`v1`は`number`型となります。

```ts
// ImmutableRecord<{}>
const m1 = empty();
// ImmutableRecord<Pick<{}, never> & { foo: string }>
const m2 = set(m1, "foo", "pikachu");
// ImmutableRecord<Pick<Pick<{}, never> & { foo: string }, never> & { foo: number }>
const m3 = set(m2, "foo", 12345);

// number
const v1 = get(m3, "foo");
```

## 型の見やすさの問題

しかし、ここで新たな問題が出てきました。`m3`の型を調べてみると、`ImmutableRecord<Pick<Pick<{}, never> & { foo: string }, never> & { foo: number }>`と書いてあります。行った操作から考えると実態は`ImmutableRecord<{ foo: number }>`のはずですが、`Pick`や`&`が出てくる複雑な型となっています。なお、`Pick`は`Omit`の内部実装で使われている型です。

このように、複雑な操作をすると**型が読みにくくなってしまう**という問題が、ある程度複雑な型プログラムを書くと顕在化します。型はプログラムの読み手を助ける役割を持っていますから、読みにくい型というのは望ましくありません。

このような問題に対する対処法の一つは、次のように定義された`Flatten`型を使うことです。

```ts
type Flatten<T> = {
  [K in keyof T]​: T[K];
};
```

これはmapped typeを使った型で、オブジェクト型の中身を計算してくれる効果を持ちます。例えば、単純なIntersection型も`Flatten`で綺麗にすることができます。

```ts
type T = { foo: string } & { bar: number };
// type U = { foo: string; bar: number }
type U = Flatten<T>;
```

このように、2つのオブジェクトのIntersection型に対して`Flatten`を適用すると、意味が同じ1つのオブジェクト型となり、より見やすくなります。

これを`set`時の`Immutable`の中に適用することで結果がより見やすくなりそうです。具体的にはこうですね。

```ts
/**
 * 受け取ったImmutableRecordに新しいキーを足してできた
 * 新しいImmutableRecordを返す
 */
declare function set<Data, Key extends string, Value>(
    map: ImmutableRecord<Data>,
    key: Key,
    value: Value,
): ImmutableRecord<Flatten<Omit<Data, Key> & {
    [K in Key]: Value
}>>;
```

こうすれば`m3`の型もより分かりやすく...

```ts
// ImmutableRecord<{}>
const m1 = empty();
// ImmutableRecord<Flatten<Pick<{}, never> & { foo: string }>>
const m2 = set(m1, "foo", "pikachu");
// ImmutableRecord<Flatten<Pick<Pick<{}, never> & { foo: string }, never> & { foo: number }>>
const m3 = set(m2, "foo", 12345);
```

![Flatten.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233667/e82d6b3e-7a81-32bd-4ca4-f6c6c7161b5b.png)

**あれ？ :thinking:**

結果を見ると、`m3`の型に`Flatten`がそのまま残っていて、結果がきれいになっていません。

このように、`Flatten`は複雑な型の中では効果を発揮しないことがあります。

## 自前のMapped Typeを使う

以上の問題に対処するには、`Flatten`という名前を付けるのをやめて型定義の中で直接mapped typeを使うという手があります。

```ts
/**
 * 受け取ったImmutableRecordに新しいキーを足してできた
 * 新しいImmutableRecordを返す
 */
declare function set<Data, Key extends string, Value>(
    map: ImmutableRecord<Data>,
    key: Key,
    value: Value,
): ImmutableRecord<
  Key extends keyof Data
    ? {
        [K in keyof Data]: K extends Key ? Value : Data[K]
      }
    : {
        [K in keyof Data | Key]:
          K extends keyof Data
            ? Data[K]
            : Value
      }>;


// ImmutableRecord<{}>
const m1 = empty();
// ImmutableRecord<{ foo: string }>
const m2 = set(m1, "foo", "pikachu");
// ImmutableRecord<{ foo: number }>
const m3 = set(m2, "foo", 12345);

// number
const v1 = get(m3, "foo");
```

ちょっと`set`の定義が長くなりましたね。ここでconditional typesが登場しました。この定義では、返り値の型はまず`Key extends keyof Data`か道家で分岐します。これは、`Key`が`keyof Data`の中に存在する（＝`Key`が既存のキーである）という条件を表しています。

既存のキーの場合はキーの上書きとなるので、`Data`と比べて新しいキーは増えません。よって、`{ [K in keyof Data]: ... }`というmapped typeを返しています。一方で、新しいキーの場合は`{ [K in keyof Data | Key]: ... }`というmapped typeを返しています。

こうすることで、`m2`や`m3`の型がすっきりしましたね。

実は、この型定義の中に出てきたmapped typeを抜き出して型名を付けると、その部分が計算されなくなってしまい結果の型が分かりにくくなってしまいます。ですから、このようにmapped typesを関数の返り値の型にベタ書きして、型の計算を強制するテクニックが有効です。

## まとめ

複雑な型の計算をする際は、計算結果の型の見やすさにまで気を配るとよいでしょう。そのためには、この記事で紹介したようなmapped typesをベタ書きするテクニックが有効です。

筆者は[Rocon](https://github.com/uhyo/rocon)の開発でこのテクニックを用いました。Roconはライブラリなので、型の読みやすさまで含めて分かりやすいインターフェースを提供することはとても重要です。