TypeScript v4.3 の機能を使って immutable ライブラリの型付けを頑張る

2021/07/05
このエントリーをはてなブックマークに追加

目標

オブジェクトの非破壊更新を行う以下のような関数 setIn を実装すること。

type R = Readonly<{ a: Readonly<{ b: Readonly<{ c: number; d: number }> }> }>;

const record0: R = { a: { b: { c: 1, d: 2 } } };

const record1: R = setIn(record0, ['a', 'b', 'c'], 999);

console.log(record0); // { a: { b: { c: 1, d: 2 } } }
console.log(record1); // { a: { b: { c: 999, d: 2 } } }

const record2: R = setIn(record0, ['a', 'b', 'c'], '999');
//                                                 ~~~~~
//                                                 ^ type error

const record3: R = setIn(record0, ['a', 'b', 'e'], 9);
//                                 ~~~~~~~~~~~~~
//                                            ^ type error

前書き

昨今のウェブフロントエンドの開発においては、データを immutable に扱うのが主流です。すなわち、データを変更するときにオブジェクトを破壊的に書き換えるのではなく、新しいオブジェクトを作って変更後のデータを作ります。あるデータがプログラムのあちこちで書き換えられることでプログラムの挙動を予測しづらくなる、という状況を避けたいというのがデータを immutable に扱う大きな理由です。

TypeScript でオブジェクトを破壊的に書き換えずに一部の値を更新した新しいオブジェクトを作る手軽な方法の一つとして、次のようにスプレッド演算子(...)を使う方法があります。

const currState = { x: 1, y: 2, z: 3 };
const nextState = { ...currState, x: 999 }; // { x: 999, y: 2, z: 3 }

これで済むケースも結構多いのですが、いくつか欠点もあり、その一つに次の例のようにオブジェクトのネストが深くなっていて深いパスにある値を変更したいとき、パスの深さに比例して記述量が多くなってしまうというものがあります。

type State = DeepReadonly<{
    a0: number;
    a1: number;
    a2: {
        b0: number;
        b1: number;
        b2: {
            c0: number;
            c1: number;
            c2: {
                d0: number;
                d1: number;
                d2: {
                    e0: number;
                    e1: number;
                    e2: number;
                };
            };
        };
    };
}>;

const currState: State = {
    a0: 0,
    a1: 0,
    a2: {
        b0: 0,
        b1: 0,
        b2: {
            c0: 0,
            c1: 0,
            c2: {
                d0: 0,
                d1: 0,
                d2: {
                    e0: 0,
                    e1: 1,
                    e2: 2,
                },
            },
        },
    },
} as const;

// currState を変化させずに currState.a2.b2.c2.d2.e2 を 999 に変更したオブジェクトを作りたい
const nextState: State = {
    ...currState,
    a2: {
        ...currState.a2,
        b2: {
            ...currState.a2.b2,
            c2: {
                ...currState.a2.b2.c2,
                d2: {
                    ...currState.a2.b2.c2.d2,
                    e2: 999,
                },
            },
        },
    },
};

ほかにも次のような欠点もあります。
例えば以下のようなよくある状態管理における reducer を実装する例を考えます。
スプレッド演算子...を用いて状態更新を行う reducer のコードを以下のように書いていたとします。

type State = Readonly<{ x: number; y: number; z: number }>;

type Action =
    | { type: 'setX'; value: number }
    | { type: 'setY'; value: number }
    | { type: 'setZ'; value: number };

export const reducer = (state: State, action: Action): State => {
    // ...
    switch (action.type) {
        case 'setX': {
            const nextState = { ...state, x: action.value };
            return nextState;
        }
        case 'setY': {
            const nextState = { ...state, y: action.value };
            return nextState;
        }
        case 'setZ': {
            const nextState = { ...state, z: action.value };
            return nextState;
        }
    }
};

ここで State の型を Readonly<{ a: number; b: number; c: number }> のように変えたとします。
このとき、nextState の中の x, y, z はそれぞれ a, b, c に書き換える必要がありますが、実はこのままでも余剰プロパティとして扱われるので、型チェックは通ってしまいます(実際に使われるプロパティは何も更新しないコードになってしまいます)。つまり、型エラーが出る箇所のみ修正していたとしたら、ここは修正漏れになってしまいます。

ただし、本題から逸れるためここでは詳しく述べませんが、TypeScript は余剰プロパティチェックという機能を持っており、 nextState を直接 return していたり、 nextState に直接型注釈 State を付けていたりする場合は余剰プロパティに気づくことができます。このため、多くのケースでは問題が顕在化しないのですが、そうはいっても型チェックでエラーになっているわけではないので上の例のような抜け穴が生まれてしまいます。

... を使ってプロパティを更新しようとすることの本質的な問題点は、元の State のプロパティをすべて展開した上で、それを「上書きする」という形のコードにならざるを得ない点です。このため、この書き方でのプロパティ更新箇所は State 型が変わったときに余剰プロパティになってしまう危険を常にはらんでいます。

これらの例を踏まえると、堅牢かつ簡潔な状態更新を行うためのもっと気の利いた道具を使った方が良さそうです。


immutable なオブジェクト操作を行うための既存の TypeScript ライブラリとしては、 Immutable.jsImmer が有名です。

immutable.js を使う場合、専用のデータ構造を使う必要がありますが、 setInupdateIn というメソッドを使って以下のようにオブジェクト(レコード)を更新することができます(少しボイラープレートコードが多いのがこのライブラリの難点ですが、永続データ構造ライブラリという側面が強いため仕方ないところかもしれません)。

import { Record as IRecord } from 'immutable';

const defaultState = {
    a0: 0,
    a1: 0,
    a2: {
        b0: 0,
        b1: 0,
        b2: {
            c0: 0,
            c1: 0,
            c2: {
                d0: 0,
                d1: 0,
                d2: {
                    e0: 0,
                    e1: 0,
                    e2: 0,
                },
            },
        },
    },
} as const;

const stateFactory = IRecord(defaultState);

const currState = stateFactory();

const nextState = currState.setIn(['a2', 'b2', 'c2', 'd2', 'e2'], 999);

ただし、immutable.js の setIn というメソッドは執筆時点の最新版 v4.0.0-rc.12 でも型が以下のようになっており、 keyPath の typo がチェックされません。

setIn(keyPath: Iterable<any>, value: any): this
const nextState = currState.setIn(['a2', 'b2', 'c2', 'd2', 'f2'], 999);
//                                                         ~~~~
//                                                         エラーにならない!

これでは肝心の堅牢性が得られません。


一方、immerを使う場合は以下のように更新箇所だけならたった 3 行で書くことができます。

import { produce } from 'immer';

const nextState: State = produce(currState, (draft) => {
    draft.a2.b2.c2.d2.e2 = 999;
});

immer では produce という関数を用いて変更を加えたいオブジェクト currState の "draft" に対して変更を加えると、Proxy を通して値の書き換えを検知しコピーオンライトで(元のオブジェクト currState を書き換えることなく)新しいオブジェクトを作って返してくれます。

immer の場合は、上の例で言う draft という変数の型は Draft<State> というほぼ State と同じ型になっており、 draftcurrState を辿るのと全く同じようにキーアクセスできるため、間違ったキーアクセスは型エラーで弾くことができます。この点では immutable.js の setIn 関数に安全性の面で勝っていると言えます。

ただし、 Draft<T>T から再帰的に readonly を外した型になっている(https://github.com/immerjs/immer/blob/master/src/types/types-external.ts#L35)ため、以下の例のように readonly な値を代入できないという問題が生じることがあります。

type State = Readonly<{
    a: readonly number[];
    b: readonly string[];
}>;

const initialState: State = {
    a: [1, 2, 3],
    b: ['1', '2', '3'],
};

const nextState = produce(initialState, (draft) => {
    draft.a = initialState.a;
    //  The type 'readonly number[]' is 'readonly' and cannot be assigned to the mutable type 'number[]'.ts(4104)
});

一応以下のように右辺を readonly を除去した型にキャストすればエラーを黙らせることはできますが、記述量も増えてしまう上にいちいちキャストが発生するのも少し気持ち悪い気がします。

type Writable<T> = { -readonly [P in keyof T]: T[P] };

const castWritable = <T>(a: T): Writable<T> => a as Writable<T>;

const nextState = produce(initialState, (draft) => {
    draft.a = castWritable(initialState.a);
});

このように、 immer を使うと左辺 draft が readonly の取れた型になるために、右辺に readonly な値を持ってくると型エラーになってしまう場合があるのが、残念なところです。
しかしながら、immer は状態更新をオブジェクトに対する破壊的更新で書けるようにすることを目的とするライブラリであるため、左辺が mutable な型であるのは仕方が無く、避けようがない問題であるように思われます。

そこで、今回はこのような状態更新を限りなく安全に書けるようにすることを目指してユーティリティを作ってみることにしました。
作るものとしては、 immutable.js の setIn メソッドに似た以下のような関数です。これを次章以降で少しずつ作っていきます。

const nextState = setIn(initialState, ['b'], ['4', '5', '6']);

実装

実装するものの全体像を再掲します。 /* implement here */ と書いているところを埋めれば完成です。

type RecordKeyType = keyof never; // number | string | symbol
type ReadonlyRecordBase = Readonly<Record<RecordKeyType, unknown>>;

type KeyPathAndValueTypeAtPathTuple<R> = /* implement here */;

export function setIn<R extends ReadonlyRecordBase>(
  record: R,
  ...[keyPath, newValue]: KeyPathAndValueTypeAtPathTuple<R>
): R {
    /* implement here */
}

この setIn 関数は、第一引数に更新対象のオブジェクト(レコード)を受け取り、その型 R を元に存在するパス keyPath とそこに格納できる型の値 newValue を第 2,3 引数に受け取るようにします。 immutable.js の setIn メソッドはこの keyPath に正確な型がついていませんでしたが、これを型安全に書き直すことが以降やっていくメインの仕事になります。

  1. レコード型 R を受け取り、 R のパスすべての union を返す Paths<R>
  2. レコード型 R のパス Path にある型を取り出す RecordValueAtPath<R, Path>
  3. Paths<R>RecordValueAtPath<R, Path> の対応するペア全体からなる union 型 KeyPathAndValueTypeAtPathTuple<R>
  4. setIn 関数の中身

を順に実装します。

実装をなるべく簡潔にするため、 keyPath については不定長の配列( string[][number, number, ...number[]] など)や index signature ({ [key: string]: hoge }{ [key: number]: hoge })などのサイズが不定の(動的な)オブジェクトが出てきた時点で、その中身にはアクセスしない(パスを打ち切る)ことにします。 また、 SetMap などの組み込みオブジェクトに対しても特別な対応はしないことにします。

使用環境

  • TypeScript v 4.3.2

準備

少々複雑な型を実装するので、型のユニットテストをするためのユーティリティを用意します。

export type TypeEq<X, Y> = (<T>() => T extends X ? 1 : 2) extends <
    T
>() => T extends Y ? 1 : 2
    ? true
    : false;

export const assertType = <_T extends true>(): void => undefined;
export const assertNotType = <_T extends false>(): void => undefined;

// 使用例
assertNotType<TypeEq<number, string>>();
assertType<TypeEq<1, 1>>();
assertType<TypeEq<[1, 2, 3], [1, 2, 3]>>();
assertType<TypeEq<readonly [1, 2, 3], readonly [1, 2, 3]>>();
assertNotType<TypeEq<any, 1>>();
assertNotType<TypeEq<1 | 2, 1>>();
assertNotType<TypeEq<any, never>>();
assertNotType<TypeEq<[any], [number]>>();
assertNotType<TypeEq<{ x: any }, { x: number }>>();

assertType<TypeEq<A, B>>()AB の型が等しいことをチェックできます。本題ではないので今回は原理については説明を省略します。 TypeEq の実装は以下の issue にあるものを参考にしています。

[Feature request]type level equal operator · Issue #27024 · microsoft/TypeScript

(余談)TypeScript の型パズルに少し慣れると type TypeEq<X, Y> = [X] extends [Y] ? [Y] extends [X] ? true : false : false というような実装も思いつくのですが、これは any が絡んでくると XY なのに true になってしまうことがあるため、このような工夫が必要になります。

それから readonly をたくさん書くのが面倒なので、再帰的に readonly を付けるための DeepReadonly 型も用意しておきます(Set や Map はここでも対象外とします)。

export type DeepReadonly<T> = T extends (...args: readonly unknown[]) => unknown
    ? T
    : T extends ReadonlyRecordBase | readonly unknown[]
    ? { readonly [P in keyof T]: DeepReadonly<T[P]> }
    : T;

assertType<
    TypeEq<
        DeepReadonly<{ a: { b: { c: [1, 2, 5] } } }>,
        {
            readonly a: {
                readonly b: {
                    readonly c: readonly [1, 2, 5];
                };
            };
        }
    >
>();

Paths<R>

Paths<R> はレコード型 R を受け取り、 R のパスすべての union を返します。これは setIn の第 2 引数 keyPath の型などに使用するものです。

Paths<R>

  • Step1 : レコード型 R の「葉までのパスすべて」の union を返す LeafPaths 型を実装する
  • Step2 : タプル型 T に対してその prefix すべての union(例えば [1, 2, 3] に対して [] | [1] | [1, 2] | [1, 2, 3] ) を返す Prefixes 型を実装する
  • Step3 : LeafPathsPrefixes を組み合わせて Paths を実装する

という 3 ステップで実装していきます。

Step1 の実装が大部分を占めていて、Step2 は比較的軽く、 Step3 も LeafPathsPrefixes を組み合わせるだけです。


Step 1 : LeafPaths<R> 型を実装する

まずレコード型 R の葉までのパスすべての union を返す LeafPaths<R> 型を作ります。「葉までの」というのは、以下の例で例えば ["x"]["y", 2, "f"] などのプレフィックスにあたるパスは除外したもの、という意味です。まずこのような型を作ってから prefix も含む union を作る、という 2 ステップにした方が分かりやすいと考えこのようにしました。

type R0 = DeepReadonly<{
    x: {
        a: 1;
        b: { x: [number, ...string[]] }[];
    };
    y: {
        c: {
            d: { x: number }[];
            4: 5;
        };
        g: [{ x: number }, ...{ y: string[] }[]];
        h: (a: number) => string;
    };
    z: [1, 2, { e: 3; f: [6, 7] }];
}>;
type K0 = LeafPaths<R0>;
assertType<
    TypeEq<
        K0,
        | readonly ['x', 'a']
        | readonly ['x', 'b']
        | readonly ['y', 'c', 'd']
        | readonly ['y', 'c', 4]
        | readonly ['y', 'g']
        | readonly ['y', 'h']
        | readonly ['z', 0]
        | readonly ['z', 1]
        | readonly ['z', 2, 'e']
        | readonly ['z', 2, 'f', 0]
        | readonly ['z', 2, 'f', 1]
    >
>();

LeafPaths は結構長くなるのですが以下のようにして実装することができます。

export type LeafPaths<R> = R extends readonly unknown[]
  ? LeafPathsImplListCase<R>
  : R extends ReadonlyRecordBase
  ? LeafPathsImplRecordCase<R>
  : readonly [];

type LeafPathsImplListCase<
  T extends readonly unknown[],
  PathHead extends keyof T = keyof T
> = T extends readonly []
  ? readonly []
  : IsInfiniteList<T> extends true
  ? readonly []
  : PathHead extends keyof T
  ? PathHead extends `${number}`
    ? readonly [ToNumber<PathHead>, ...LeafPaths<T[PathHead]>]
    : never
  : never;

type LeafPathsImplRecordCase<
  R extends ReadonlyRecordBase,
  PathHead extends keyof R = keyof R
> = string extends PathHead
  ? readonly []
  : PathHead extends keyof R
  ? readonly [PathHead, ...LeafPaths<R[PathHead]>]
  : never;

export type IsInfiniteList<T extends readonly unknown[]> =
  number extends T['length'] ? true : false;

export type ToNumber<S extends `${number}`> = /* 省略 */
assertType<TypeEq<ToNumber<'1000'>, 1000>>();
assertType<TypeEq<ToNumber<'8192'>, 8192>>();
assertType<TypeEq<ToNumber<'9999'>, 9999>>();

例として LeafPaths<R0> がどう展開されるのかを説明します。

まず、R0 はレコード型なので LeafPaths の中の条件 R0 extends ReadonlyRecordBase にマッチし LeafPathsImplRecordCase が呼び出されます。
第 2 型引数の PathHead extends keyof R = keyof R は引数というよりは型変数 PathHead を宣言しておくために置いています。
string extends PathHead ? readonly [] のところは index signature の場合を除外する(=再帰を止める)ためにあります。 PathHead = 'a' | 'b' などは string を部分型に含まないためマッチしませんが、 R = Record<string, number> とかなら PathHead = keyof R = string はこれにマッチして再帰がここで止まります。
PathHead extends keyof R という部分は、 PathHeadkeyof R なので常に true になり一見意味が無さそうに見えますが、 union distribution を起こすために挟んでいます。union 型(ここでは 'x' | 'y' | 'z')の要素について配列の map のような処理を行いたいときによく使うテクニックです(参考: TypeScript の型初級 – # conditional type における union distribution)。

type F<X> = X extends X ? [X] : never;
// X に union 型 A | B が入ってくると、 union distribution により
// (A extends A ? [A] : never) | (B extends B ? [B] : never)
// に展開される。

type A = F<1 | 2 | 3>;
// A は [1] | [2] | [3] という型になる

いま PathHead = 'x' | 'y' | 'z' なので、

PathHead extends keyof R
  ? readonly [PathHead, ...LeafPaths<R[PathHead]>]
  : never;

という部分で 'x' | 'y' | 'z' が分配されて

('x' extends 'x'
    ? readonly ['x', ...LeafPaths<R['x']>]
    : readonly [] ) |
('y' extends 'y'
    ? readonly ['y', ...LeafPaths<R['y']>]
    : readonly [] ) |
('z' extends 'z'
    ? readonly ['z', ...LeafPaths<R['z']>]
    : readonly [] )

という union になり、それぞれ true 部に簡約されて

(readonly ['x', ...LeafPaths<R['x']>]) |
(readonly ['y', ...LeafPaths<R['y']>]) |
(readonly ['z', ...LeafPaths<R['z']>])

となります。
それぞれの key について再帰的に LeafPaths が呼ばれるのですが、ここまでと同様の展開で 'x' の部分は以下のように簡約されていきます。

readonly ['x', ...LeafPaths<R['x']>]
 -> readonly ['x', ...(['a'] | ['b'])]
 -> readonly ['x', ...['a']] | readonly ['x', ...['b']]
 -> readonly ['x', 'a'] | readonly ['x', 'b']

2 行目から 3 行目への簡約は Variadic Tuple Types の union distribution が行われます(Variadic Tuple Types の PR...TT が union のときは distribute されるという仕様が書かれています)。
LeafPaths<R['z']> の方は、 R['z'] = readonly [1, 2, { e: 3; f: [6, 7] }] なので R extends readonly unknown[] の配列型のケースにマッチし LeafPathsImplListCase が呼び出されます。

type LeafPathsImplListCase<
    T extends readonly unknown[],
    PathHead extends keyof T = keyof T
> = T extends readonly []
    ? readonly []
    : IsInfiniteList<T> extends true
    ? readonly []
    : PathHead extends keyof T
    ? PathHead extends `${number}`
        ? readonly [ToNumber<PathHead>, ...LeafPaths<T[PathHead]>]
        : never
    : never;

IsInfiniteList<T> extends true のところは不定長の配列型の場合は再帰をストップするための処理です。 IsInfiniteList は、固定長のタプル型の length がその具体的な長さの数値リテラルになる(たとえば [1, 3, 6]['length'] = 3 )ことを利用して number 以上に広い型になるかどうかで以下の判定できます。

export type IsInfiniteList<T extends readonly unknown[]> =
    number extends T['length'] ? true : false;

PathHead extends keyof T はレコード型のケースと同様で union distribution のための行です。

その次の行は、タプル型 T のキーのうち index の数値を表す文字列になっているもののみをフィルタしています。タプル型のキー集合( keyof [1, 2, 3] など)に含まれている "0", "1", "2", …, "toString" , … などのキーのうち添え字のキー "0", "1", "2" のみを取り出す処理です。

readonly [ToNumber<PathHead>, ...LeafPaths<T[PathHead]>] の行はレコード型のときとほぼ同じ再帰ですが、 "0"0 という変換をしてあげるために ToNumber をかませています。 ToNumber の実装はトリッキーで脇道にそれるのでここでは省略します。以下の記事の実装をほぼそのまま使いました。

TypeScript にヤバい機能が入りそうなのでひとしきり遊んでみる| TechRacho(テックラッチョ)〜エンジニアの「?」を「!」に〜| BPS 株式会社

あとは "0", "1", "2" について再帰されるのですが、 "0" の再帰は ...LeafPaths<T["0"]>T["0"] = readonly [1, 2, { e: 3; f: [6, 7] }]["0"] = 1 より、 ... readonly [] に展開されるため、この再帰の結果のパスは readonly ['z', 0] となります。

同様にして他のパスも辿られて、最後に union distribute された各パスの union が返されるので、 LeafPaths<R0>

| readonly ['x', 'a']
| readonly ['x', 'b']
| readonly ['y', 'c', 'd']
| readonly ['y', 'c', 4]
| readonly ['y', 'g']
| readonly ['y', 'h']
| readonly ['z', 0]
| readonly ['z', 1]
| readonly ['z', 2, 'e']
| readonly ['z', 2, 'f', 0]
| readonly ['z', 2, 'f', 1]

という型になります。


Step 2 : Prefixes<T> を実装する

setIn の第 2 引数 keyPath は、オブジェクトの末端までのパスだけでなく途中のパスにもマッチする必要があるため、LeafPaths の結果の union にそのすべての prefix にあたるパスを追加する必要があります。

あるタプル型 T を受け取りその prefix すべてからなる union を返す型は以下のように定義できます。

type Prefixes<T extends readonly unknown[]> = T extends readonly [
    infer Head,
    ...infer Rest
]
    ? readonly [] | readonly [Head, ...Prefixes<Rest>]
    : readonly [];

assertType<
    TypeEq<
        Prefixes<readonly [1, 2, 3]>,
        readonly [] | readonly [1, 2, 3] | readonly [1, 2] | readonly [1]
    >
>();

例えば Prefix[1, 2, 3] を渡したときの動作は以下のようになります。

Prefixes<[1,2,3]>
  -> [] | [1, ...Prefixes<[2,3]>]
  -> [] | [1, ...([] | [2, ...Prefixes<[3]>])]
  -> [] | [1, ...([] | [2, ...([] | [3, ...Prefixes<[]>])])]
  -> [] | [1, ...([] | [2, ...([] | [3, ...[]])])]
  -> [] | [1, ...([] | [2, ...([] | [3])])]
  -> [] | [1, ...([] | ([2] | [2, 3]))]
  -> [] | [1, ...([] | [2] | [2, 3])]
  -> [] | ([1] | [1, 2] | [1, 2, 3])
  -> [] | [1] | [1, 2] | [1, 2, 3]

Step 3 : Paths<R> を実装する

これは単に

type Paths<R> = Prefixes<LeafPaths<R>>;

とするだけです。これでなぜ良いかというと、 Prefixes の引数 T に union 型 A | B | C が渡ってくると union distribution によりそれぞれの union の要素について Prefixes が施された結果の union になるためです。これにより、 Paths<R0> の結果は次のようになります。

  readonly []
| readonly ['x']
| readonly ['x', 'a']
| readonly ['x', 'b']
| readonly ['y']
| readonly ['y', 'c']
| readonly ['y', 'c', 'd']
| readonly ['y', 'c', 4]
| readonly ['y', 'g']
| readonly ['y', 'h']
| readonly ['z']
| readonly ['z', 0]
| readonly ['z', 1]
| readonly ['z', 2]
| readonly ['z', 2, 'e']
| readonly ['z', 2, 'f']
| readonly ['z', 2, 'f', 0]
| readonly ['z', 2, 'f', 1]

以上で Paths 型の実装が出来上がりました。


RecordValueAtPath<R, Path>

レコード型 R のパス Path にある型を取り出す型です。 setIn 関数の第 3 引数 newValue の型に用います。

これは Paths に比べるとだいぶ簡単です。 Path は先ほど作った Paths<R> に含まれるパスのみが入ってくるので、安心してそれを先頭から辿り再帰的に R を掘っていけばよいです。

type RecordValueAtPath<R, Path extends Paths<R>> = Path extends readonly [
    infer Head,
    ...infer Rest
]
    ? Head extends keyof R
        ? Rest extends Paths<R[Head]>
            ? RecordValueAtPath<R[Head], Rest>
            : never
        : never
    : R;

Path extends readonly [infer Head, ...infer Rest] とすることで Path を先頭要素と残りに分けることができます。

Head extends keyof RRest extends Paths<R[Head]> は必ず true になるのですが、追加しないと RecordValueAtPath<R[Head], Rest> でエラーが出るので追加しています。

Path extends readonly [infer Head, ...infer Rest] は、長さ 1 以上であるという条件でもあるので、長さ 0 の Path が来たときは R に評価されます。

type R0 = DeepReadonly<{
    x: {
        a: 1;
        b: { x: [number, ...string[]] }[];
    };
    y: {
        c: {
            d: { x: number }[];
            4: 5;
        };
        g: [{ x: number }, ...{ y: string[] }[]];
        h: (a: number) => string;
    };
    z: [1, 2, { e: 3; f: [6, 7] }];
}>;
assertType<TypeEq<RecordValueAtPath<R0, readonly ['z', 2, 'f', 1]>, 7>>();

KeyPathAndValueTypeAtPathTuple<R>

R の各パスとそのパスにある値の型のペアのタプル全体からなる型です。これは setIn 関数の第 2・3 引数の型に使います。
Paths<R> の union の各要素(Rの各パス)に、そのパスにある値の型をくっつけたペアの型を生成しています。

type AttachValueTypeAtPath<R, Path extends Paths<R>> = Path extends unknown
    ? readonly [Path, RecordValueAtPath<R, Path>]
    : never;

type KeyPathAndValueTypeAtPathTuple<R> = AttachValueTypeAtPath<R, Paths<R>>;

assertType<TypeEq<KeyPathAndValueTypeAtPathTuple<R0>[0], Paths<R0>>>();

assertType<
    TypeEq<
        DeepReadonly<
            | [
                  ['y', 'c'],
                  {
                      d: { x: number }[];
                      4: 5;
                  }
              ]
            | [
                  ['y'],
                  {
                      c: {
                          d: { x: number }[];
                          4: 5;
                      };
                      g: [{ x: number }, ...{ y: string[] }[]];
                      h: (a: number) => string;
                      i: (a: string) => string;
                  }
              ]
            | [['x', 'a'], 1]
            | [['x', 'b'], { x: [number, ...string[]] }[]]
            | [['x'], { a: 1; b: { x: [number, ...string[]] }[] }]
            | [['y', 'c', 'd'], { x: number }[]]
            | [['y', 'c', 4], 5]
            | [['y', 'g'], [{ x: number }, ...{ y: string[] }[]]]
            | [['y', 'h'], (a: number) => string]
            | [['y', 'i'], (a: string) => string]
            | [['z', 0], 1]
            | [['z', 1], 2]
            | [['z', 2, 'e'], 3]
            | [['z', 2, 'f', 0], 6]
            | [['z', 2, 'f', 1], 7]
            | [['z', 2, 'f'], [6, 7]]
            | [['z', 2], { e: 3; f: [6, 7] }]
            | [['z'], [1, 2, { e: 3; f: [6, 7] }]]
            | [[], R0]
        >,
        KeyPathAndValueTypeAtPathTuple<R0>
    >
>();

setIn の関数本体

(途中端折りましたが)ようやく型が出来上がったので、最後に関数本体を実装します。

const UNSAFE_setIn_impl = (
    record: ReadonlyRecordBase,
    keyPath: readonly (number | string)[],
    index: number,
    newValue: unknown
): unknown =>
    index >= keyPath.length
        ? newValue
        : Array.isArray(record)
        ? record.map((v, i): unknown =>
              i === keyPath[index]
                  ? UNSAFE_setIn_impl(
                        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                        record[keyPath[index]!] as ReadonlyRecordBase,
                        keyPath,
                        index + 1,
                        newValue
                    )
                  : v
          )
        : {
              ...record,
              // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
              [keyPath[index]!]: UNSAFE_setIn_impl(
                  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                  record[keyPath[index]!] as ReadonlyRecordBase,
                  keyPath,
                  index + 1,
                  newValue
              ),
          };

export const setIn = <R extends ReadonlyRecordBase>(
    record: R,
    ...[keyPath, newValue]: KeyPathAndValueTypeAtPathTuple<R>
): R =>
    UNSAFE_setIn_impl(record, keyPath as readonly string[], 0, newValue) as R;

型キャストが多くやや見づらいですが、 setIn(record, keyPath, newValue)UNSAFE_setIn_impl(record, keyPath, 0, newValue) を呼び出していて、第 3 引数の index が再帰で 1 ずつ増えていき、 keyPath の末尾に到達した時点で newValue を返す、というようにしています。
UNSAFE_setIn_implの内部の型は嘘だらけですが、 setIn のみ export するのでよいこととします。

内部実装は UNSAFE_setIn_impl のように自前で書かなくても immer を使ってもよいと思いますが、今回は依存ライブラリ無しで作る例として載せました。

完成品

まとめ

今回はオブジェクトの非破壊更新を行うライブラリを作ってみました。JavaScript のコードとしてはスプレッド演算子を使っているだけの素朴な実装ですが、ウェブフロントエンドの状態管理コードなどで使用することを想定しているので、型がしっかりついている安心と便利さをまずは重視しました。
このライブラリは十分実用的で、ランタイムが非常に小さい(setIn の関数本体の節に書いたコードのみ)点がメリットですが、 immer と比べて機能面で劣っている点もあります(たとえば長さ不定の配列の一部の書き換えには対応していません。実はそのようなライブラリも自作しているのですが本記事では量が膨れすぎるため載せませんでした。)。機能面で不足するのであれば、序章で示したように writable へのキャストを行うことを許容して immer を使うのがリーズナブルかなと思います。

以上、 noshiro が書きました。

その他の記事

Other Articles

2022/06/03
拡張子に Web アプリを関連付ける File Handling API の使い方

2022/03/22
<selectmenu> タグできる子; <select> に代わるカスタマイズ可能なドロップダウンリスト

2022/03/02
Java 15 のテキストブロックを横目に C# 11 の生文字列リテラルを眺めて ECMAScript String dedent プロポーザルを想う

2021/10/13
Angularによる開発をできるだけ型安全にするためのKabukuでの取り組み

2021/09/30
さようなら、Node.js

2021/09/30
Union 型を含むオブジェクト型を代入するときに遭遇しうるTypeScript型チェックの制限について

2021/09/16
[ECMAScript] Pipe operator 論争まとめ – F# か Hack か両方か

2021/06/25
Denoでwasmを動かすだけの話

2021/05/18
DOMMatrix: 2D / 3D 変形(アフィン変換)の行列を扱う DOM API

2021/03/29
GoのWASMがライブラリではなくアプリケーションであること

2021/03/26
Pythonプロジェクトの共通のひな形を作る

2021/03/25
インラインスタイルと Tailwind CSS と Tailwind CSS 入力補助ライブラリと Tailwind CSS in JS

2021/03/23
Serverless NEGを使ってApp Engineにカスタムドメインをワイルドカードマッピング

2021/01/07
esbuild の機能が足りないならプラグインを自作すればいいじゃない

2020/08/26
TypeScriptで関数の部分型を理解しよう

2020/06/16
[Web フロントエンド] esbuild が爆速すぎて webpack / Rollup にはもう戻れない

2020/03/19
[Web フロントエンド] Elm に心折れ Mint に癒しを求める

2020/02/28
さようなら、TypeScript enum

2020/02/14
受付のLooking Glassに加えたひと工夫

2020/01/28
カブクエンジニア開発合宿に行ってきました 2020冬

2020/01/30
Renovateで依存ライブラリをリノベーションしよう 〜 Bitbucket編 〜

2019/12/27
Cloud Tasks でも deferred ライブラリが使いたい

2019/12/25
*, ::before, ::after { flex: none; }

2019/12/21
Top-level awaitとDual Package Hazard

2019/12/20
Three.jsからWebGLまで行きて帰りし物語

2019/12/18
Three.jsに入門+手を検出してAR.jsと組み合わせてみた

2019/12/04
WebXR AR Paint その2

2019/11/06
GraphQLの入門書を翻訳しました

2019/09/20
Kabuku Connect 即時見積機能のバックエンド開発

2019/08/14
Maker Faire Tokyo 2019でARゲームを出展しました

2019/07/25
夏休みだョ!WebAssembly Proposal全員集合!!

2019/07/08
鵜呑みにしないで! —— 書籍『クリーンアーキテクチャ』所感 ≪null 篇≫

2019/07/03
W3C Workshop on Web Games参加レポート

2019/06/28
TypeScriptでObject.assign()に正しい型をつける

2019/06/25
カブクエンジニア開発合宿に行ってきました 2019夏

2019/06/21
Hola! KubeCon Europe 2019の参加レポート

2019/06/19
Clean Resume きれいな環境できれいな履歴書を作成する

2019/05/20
[Web フロントエンド] 状態更新ロジックをフレームワークから独立させる

2019/04/16
C++のenable_shared_from_thisを使う

2019/04/12
OpenAPI 3 ファーストな Web アプリケーション開発(Python で API 編)

2019/04/08
WebGLでレイマーチングを使ったCSGを実現する

2019/03/29
その1 Jetson TX2でk3s(枯山水)を動かしてみた

2019/04/02
『エンジニア採用最前線』に感化されて2週間でエンジニア主導の求人票更新フローを構築した話

2019/03/27
任意のブラウザ上でJestで書いたテストを実行する

2019/02/08
TypeScript で “radian” と “degree” を間違えないようにする

2019/02/05
Python3でGoogle Cloud ML Engineをローカルで動作する方法

2019/01/18
SIGGRAPH Asia 2018 参加レポート

2019/01/08
お正月だョ!ECMAScript Proposal全員集合!!

2019/01/08
カブクエンジニア開発合宿に行ってきました 2018秋

2018/12/25
OpenAPI 3 ファーストな Web アプリケーション開発(環境編)

2018/12/23
いまMLKitカスタムモデル(TF Lite)は使えるのか

2018/12/21
[IoT] Docker on JetsonでMQTTを使ってCloud IoT Coreと通信する

2018/12/11
TypeScriptで実現する型安全な多言語対応(Angularを例に)

2018/12/05
GASでCompute Engineの時間に応じた自動停止/起動ツールを作成する 〜GASで簡単に好きなGoogle APIを叩く方法〜

2018/12/02
single quotes な Black を vendoring して packaging

2018/11/14
3次元データに2次元データの深層学習の技術(Inception V3, ResNet)を適用

2018/11/04
Node Knockout 2018 に参戦しました

2018/10/24
SIGGRAPH 2018参加レポート-後編(VR/AR)

2018/10/11
Angular 4アプリケーションをAngular 6に移行する

2018/10/05
SIGGRAPH 2018参加レポート-特別編(VR@50)

2018/10/03
Three.jsでVRしたい

2018/10/02
SIGGRAPH 2018参加レポート-前編

2018/09/27
ズーム可能なSVGを実装する方法の解説

2018/09/25
Kerasを用いた複数入力モデル精度向上のためのTips

2018/09/21
競技プログラミングの勉強会を開催している話

2018/09/19
Ladder Netwoksによる半教師あり学習

2018/08/10
「Maker Faire Tokyo 2018」に出展しました

2018/08/02
Kerasを用いた複数時系列データを1つの深層学習モデルで学習させる方法

2018/07/26
Apollo GraphQLでWebサービスを開発してわかったこと

2018/07/19
【深層学習】時系列データに対する1次元畳み込み層の出力を可視化

2018/07/11
きたない requirements.txt から Pipenv への移行

2018/06/26
CSS Houdiniを味見する

2018/06/25
不確実性を考慮した時系列データ予測

2018/06/20
Google Colaboratory を自分のマシンで走らせる

2018/06/18
Go言語でWebAssembly

2018/06/15
カブクエンジニア開発合宿に行ってきました 2018春

2018/06/08
2018 年の tree shaking

2018/06/07
隠れマルコフモデル 入門

2018/05/30
DASKによる探索的データ分析(EDA)

2018/05/10
TensorFlowをソースからビルドする方法とその効果

2018/04/23
EGLとOpenGLを使用するコードのビルド方法〜libGLからlibOpenGLへ

2018/04/23
技術書典4にサークル参加してきました

2018/04/13
Python で Cura をバッチ実行するためには

2018/04/04
ARCoreで3Dプリント風エフェクトを実現する〜呪文による積層造形映像制作の舞台裏〜

2018/04/02
深層学習を用いた時系列データにおける異常検知

2018/04/01
音声ユーザーインターフェースを用いた新方式積層造形装置の提案

2018/03/31
Container builderでコンテナイメージをBuildしてSlackで結果を受け取る開発スタイルが捗る

2018/03/23
ngUpgrade を使って AngularJS から Angular に移行

2018/03/14
Three.jsのパフォーマンスTips

2018/02/14
C++17の新機能を試す〜その1「3次元版hypot」

2018/01/17
時系列データにおける異常検知

2018/01/11
異常検知の基礎

2018/01/09
three.ar.jsを使ったスマホAR入門

2017/12/17
Python OpenAPIライブラリ bravado-core の発展的な使い方

2017/12/15
WebAssembly(wat)を手書きする

2017/12/14
AngularJS を Angular に移行: ng-annotate 相当の機能を TypeScrpt ファイルに適用

2017/12/08
Android Thingsで4足ロボットを作る ~ Android ThingsとPCA9685でサーボ制御)

2017/12/06
Raspberry PIとDialogflow & Google Cloud Platformを利用した、3Dプリンターボット(仮)の開発 (概要編)

2017/11/20
カブクエンジニア開発合宿に行ってきました 2017秋

2017/10/19
Android Thingsを使って3Dプリント戦車を作ろう ① ハードウェア準備編

2017/10/13
第2回 魁!! GPUクラスタ on GKE ~PodからGPUを使う編~

2017/10/05
第1回 魁!! GPUクラスタ on GKE ~GPUクラスタ構築編~

2017/09/13
「Maker Faire Tokyo 2017」に出展しました。

2017/09/11
PyConJP2017に参加しました

2017/09/08
bravado-coreによるOpenAPIを利用したPythonアプリケーション開発

2017/08/23
OpenAPIのご紹介

2017/08/18
EuroPython2017で2名登壇しました。

2017/07/26
3DプリンターでLチカ

2017/07/03
Three.js r86で何が変わったのか

2017/06/21
3次元データへの深層学習の適用

2017/06/01
カブクエンジニア開発合宿に行ってきました 2017春

2017/05/08
Three.js r85で何が変わったのか

2017/04/10
GCPのGPUインスタンスでレンダリングを高速化

2017/02/07
Three.js r84で何が変わったのか

2017/01/27
Google App EngineのFlexible EnvironmentにTmpfsを導入する

2016/12/21
Three.js r83で何が変わったのか

2016/12/02
Three.jsでのクリッピング平面の利用

2016/11/08
Three.js r82で何が変わったのか

2016/12/17
SIGGRAPH 2016 レポート

2016/11/02
カブクエンジニア開発合宿に行ってきました 2016秋

2016/10/28
PyConJP2016 行きました

2016/10/17
EuroPython2016で登壇しました

2016/10/13
Angular 2.0.0ファイナルへのアップグレード

2016/10/04
Three.js r81で何が変わったのか

2016/09/14
カブクのエンジニアインターンシッププログラムについての詩

2016/09/05
カブクのエンジニアインターンとして3ヶ月でやった事 〜高橋知成の場合〜

2016/08/30
Three.js r80で何が変わったのか

2016/07/15
Three.js r79で何が変わったのか

2016/06/02
Vulkanを試してみた

2016/05/20
MakerGoの作り方

2016/05/08
TensorFlow on DockerでGPUを使えるようにする方法

2016/04/27
Blenderの3DデータをMinecraftに送りこむ

2016/04/20
Tensorflowを使ったDeep LearningにおけるGPU性能調査

→
←

関連職種

Recruit

→
←

お客様のご要望に「Kabuku」はお応えいたします。
ぜひお気軽にご相談ください。

お電話でも受け付けております
03-6380-2750
営業時間:09:30~18:00
※土日祝は除く