🤖

TypeScriptでlodash.getみたいなドット区切りのオブジェクトパス呼び出しに型をつける型パズル β

2022/03/23に公開1

はい。
どういうのかというとですね、古のJavaScriptでは一部のめんどくさがりの間で便利に使われていたこともあった lodash.get_.get(something, 'a.b.c') と書くと something.a.b.c と等価になるとかそういうアレです。

これに無理矢理型をつけるという試みです。
すでに先駆者はいるとおもうんですが、あんまり見当たらなかったので、書きます。
なおArrayに対するindex記法はサポートしませんが、やろうとおもえばちょっと書き足すだけでやれるとおもいます。

では書きます。

type Fieldable = string | number;

type Fields2Dots<Obj, Prev extends string = ''> = Obj extends Fieldable
  ? `${Prev}`
  : Obj extends Record<string, unknown>
  ? {
      [K in Extract<keyof Obj, string>]: Obj[K] extends Fieldable ? `${Prev}${K}` : Fields2Dots<Obj[K], `${Prev}${K}.`>;
    }
  : '';

type Flatten<Obj extends string | Record<string, unknown>> = Obj extends string
  ? Obj
  : Obj extends Record<string, infer V>
  ? V extends string
    ? V
    : V extends Record<string, unknown>
    ? Flatten<V>
    : never
  : never;

export type ObjectDefinition2DotsNotation<Node> = Flatten<Fields2Dots<Node>>;

まず Fieldable で探索したい末尾の型を列挙します。今回は文字列と数字だけです。

Fields2Dots<Obj> は与えられたObjを再起的に探索して、終点にいままで遡って来たパスを . 区切りで列挙します。
あとは最後にFlattenで末尾のドット区切り文字列を列挙します。

ほんとはFields2Dotsで一息に展開までやりたかったんですが、しばらく型パズルをしていなくて脳がなまっており、わかりませんでした。

ではテストを書いてみましょう。

  test('', () => {
    type Sample = {
      callable: string;
      abc: {
        hoge: string;
        xyz: {
          tyh: string;
        };
      };
    };

    type SampleToDots = ObjectDefinition2DotsNotation<Sample>;

    expectType<SampleToDots>('callable');
    expectType<SampleToDots>('abc.hoge');
    expectType<SampleToDots>('abc.xyz.tyh');
    // @ts-expect-error accessor `abc.xyz` does not returns string but object so ObjectDefinition2DotsNotation won't generate the definition.
    expectType<SampleToDots>('abc.xyz');
  });

やりましたね。

Discussion

nap5nap5

flatライブラリdot-path-valueライブラリでワークアラウンドしてみました。

デモコードです。
https://codesandbox.io/p/sandbox/shy-wood-qs5i92?file=%2Fsrc%2Findex.ts

import { flatten } from "flat";
import { getByPath } from "dot-path-value";
import type { Path } from "dot-path-value";

type Foo = {
  life: number;
  items: string[];
  users: { id: number; name: string }[];
  a: { b: string; c: boolean; d: number[] };
};

const item: Foo = {
  life: 1,
  items: ["A", "B", "C"],
  users: [
    {
      id: 1,
      name: "Cowboy",
    },
    {
      id: 2,
      name: "Bebop",
    },
  ],
  a: {
    b: "@",
    c: false,
    d: [1, 2, 3],
  },
};

const q = <T extends Record<string, unknown>>(data: T) => {
  return (path: Path<T>) => {
    return getByPath(data, path);
  };
};
const flattenedItem = flatten(item);
console.log(flattenedItem);
const result = q<Foo>(item)("users.0.name");
console.log(result);

簡単ですが、以上です。