Skip to content

Instantly share code, notes, and snippets.

@kurgm
Created November 19, 2022 15:40
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kurgm/7f2febbcbc5361a46087fea5fec817b8 to your computer and use it in GitHub Desktop.
Save kurgm/7f2febbcbc5361a46087fea5fec817b8 to your computer and use it in GitHub Desktop.
TypeScriptでgetterに型をつける

問題

以下の makeGetter 関数に型をつけることを考える。

const makeGetter = (obj) => {
  return (key) => obj[key];
};

ただし、makeGetter が返す関数の型は、 obj の型に応じてオーバーロードされた状態になっていてほしい。つまり

type Author = {
  name: string;
  age: number;
};
declare const author: Author;

const getter = makeGetter(author);

const v1 = getter("name"); // const v1: string
const v2 = getter("age");  // const v2: number
const v3 = getter("email"); // error!!
//                ^^^^^^^  No overload matches this call.

解法

方針として、 makeGetter は次のような形で型をつけることにする。

const makeGetter = <T extends object>(obj: T): Getter<T> => {
  return ((key: keyof T) => obj[key]) as Getter<T>;
};

ここで Getter<T> はこれから定義する型だが、次のような複数のシグネチャをオーバーロードした関数型を生成できればよい。

type G = Getter<Author>;
// type G = {
//   (key: "name"): string;
//   (key: "age"): number;
// };

このような Getter<T> を定義するのがゴールとなる。

ゴールを観察すると T(例では Author)の各プロパティに対して 1 つのシグネチャが対応するから、まず mapped type を使ってプロパティごとに対応するシグネチャを作る。

type Temp1<T extends object> =
  {
    [P in keyof T]: (key: P) => T[P]
  };

type Temp1_Author = Temp1<Author>;
// type Temp1_Author = {
//   name: (key: "name") => string;
//   age: (key: "age") => number;
// }

プロパティ値だけを取り出すと

type Temp2<T extends object> =
  {
    [P in keyof T]: (key: P) => T[P]
  }[
    keyof T
  ];

type Temp2_Author = Temp2<Author>;
// type Temp2_Author = ((key: "name") => string) | ((key: "age") => number)

この Temp2 で得られる型は、関数型の union の形になっており、欲しかったオーバーロードにはなっていない。

declare const temp2: Temp2_Author;

const v4 = temp2("name");
//               ^^^^^^  Argument of type 'string' is not assignable to parameter of type 'never'.

そこで巷で union to intersection と呼ばれているテクニックを使って union を intersection に変換してやる。

type UnionToIntersection<U> =
  (U extends U ? (u: U) => unknown : never) extends (i: infer I) => unknown ? I : never;

type Getter1<T extends object> =
  UnionToIntersection<
    {
      [P in keyof T]: (key: P) => T[P]
    }[
      keyof T
    ]
  >;

これでうまくいく。

declare const getter1: Getter1<Author>;
// const getter1: ((key: "name") => string) & ((key: "age") => number)

const v5 = getter1("name"); // const v5: string
const v6 = getter1("age");  // const v6: number
const v7 = getter1("email"); // error!!
//                 ^^^^^^^  No overload matches this call.

しかし、optional なプロパティがあると動かなくなる。

type Reputation = {
  source?: string;
  value: number;
};

declare const getter1_reputation: Getter1<Reputation>;
// const getter1_reputation: never

const v8 = getter1_reputation("source");
//         ^^^^^^^^^^^^^^^^^^  This expression is not callable. Type 'never' has no call signatures.

ので、これに対処するため mapped type のところで optional を解除する。

type Getter2<T extends object> =
  UnionToIntersection<
    {
      [P in keyof T]-?: (key: P) => T[P]
    }[
      keyof T
    ]
  >;

declare const getter2_reputation: Getter2<Reputation>;

const v9 = getter2_reputation("source"); // const v9: string | undefined

AuthorReputation のような“ふつうの”オブジェクト型ならこれでうまくいく。だが、配列型を入れてみると、なんだかおかしなことになってしまう。

declare const getter2_array: Getter2<bigint[]>;
// const getter2_array: number & ((key: number) => bigint) & (() => IterableIterator<(key: number) => bigint>) & (() => {
//     copyWithin: boolean;
//     entries: boolean;
//     fill: boolean;
//     find: boolean;
//     findIndex: boolean;
//     keys: boolean;
//     values: boolean;
// }) & ... 28 more ... & ((searchElement: (key: number) => bigint, fromIndex?: number | undefined) => boolean)

const v10 = getter2_array(0); // const v10: bigint  ←これはよい
const v11 = getter2_array(); // const v11: IterableIterator<(key: number) => bigint>  ←???
const v12 = getter2_array("0"); // const v12: string  ←?????

この原因は、根本的には homomorphic mapped type では in keyof TT が配列型やタプル型の場合にふつうのオブジェクトとは挙動が変わることにある。 (T がプリミティブ型の場合も特殊な挙動をするが、今回は T extends object の制約があるので気にしない。)

T が配列型かタプル型のときは index を number 型に制限して配列のメソッドを除くことで問題を回避できる。

type Getter3<T extends object> =
  UnionToIntersection<
    {
      [P in keyof T]-?: (key: P) => T[P]
    }[
      T extends readonly unknown[] ? number & keyof T : keyof T
    ]
  >;

declare const getter3_array: Getter3<bigint[]>;
// const getter3_array: (key: number) => bigint

const v13 = getter3_array(0); // const v13: bigint
const v14 = getter3_array(); // error!!
//          ^^^^^^^^^^^^^^^  Expected 1 arguments, but got 0.
const v15 = getter3_array("0"); // error!!
//                        ^^^  Argument of type 'string' is not assignable to parameter of type 'number'.

タプル型でどうなるか試してみる。

declare const getter3_tuple: Getter3<[string, boolean?]>;
// const getter3_tuple: ((key: "0") => string) & ((key: "1") => boolean | undefined)

const v16 = getter3_tuple(0);
//                        ^  No overload matches this call.
const v17 = getter3_tuple("0"); // const v17: string

getter3_tuple のパラメータ型が文字列化されており 0 のような数値を渡せなくなっている。

配列型の場合と揃っていないのは変なので、数値っぽいプロパティ名には数値の型でもアクセスできるようにする。

type StringToNumber<P> =
  P extends `${infer N extends number}` ? `${N}` extends P ? N : never : never;

type Getter<T extends object> =
  UnionToIntersection<
    {
      [P in keyof T]-?: (key: P | StringToNumber<P>) => T[P]
    }[
      T extends readonly unknown[] ? number & keyof T : keyof T
    ]
  >;

これでとりあえずできあがり。

declare const getter_tuple: Getter<[string, boolean?]>;
// const getter_tuple: ((key: 0 | "0") => string) & ((key: 1 | "1") => boolean | undefined)

const v18 = getter_tuple(0); // const v18: string
const v19 = getter_tuple(1); // const v19: boolean | undefined
// 文字列もOK
const v20 = getter_tuple("0"); // const v20: string

配列型や通常のオブジェクト型で問題が出ていないことを確認する。

declare const getter_array: Getter<bigint[]>;
// const getter_array: (key: number) => bigint
const v21 = getter_array(0); // const v21: bigint

declare const getter_reputation: Getter<Reputation>;
// const getter_reputation: ((key: "source") => string | undefined) & ((key: "value") => number)
const v22 = getter_reputation("source"); // const v22: string | undefined
const v23 = getter_reputation("value"); // const v23: number

実際に、最初に型を付けた makeGetter が安全に使えることを確かめる。

const getter_temp1 = makeGetter({ text: "歯を磨く", priority: 30 });
const v24 = getter_temp1("text"); // const v24: string
const v25 = getter_temp1("deadline"); // error!!
//                       ^^^^^^^^^^  No overload matches this call.

const getter_temp2 = makeGetter({ red: "#FF0000", blue: "#0000FF" } as const);
const v26 = getter_temp2("red"); // const v26: "#FF0000"

const getter_temp3 = makeGetter([1, 1, 2, 3, 5, 8, 13, 21, 34, 55]);
const v27 = getter_temp3(1); // const v27: number
const v28 = getter_temp3("reverse"); // error!!
//                       ^^^^^^^^^  Argument of type 'string' is not assignable to parameter of type 'number'.

const getter_temp4 = makeGetter([0, 255, 0] as const);
const v29 = getter_temp4(1); // const v29: 255
const v30 = getter_temp4(3); // error!!
//                       ^  No overload matches this call.

発展問題

makeGetter のパラメータがunionのとき

declare const getter_union: Getter<Author | Reputation>;
// const getter_union: unknown

const v31 = getter_union("name");
//          ^^^^^^^^^^^^  Object is of type 'unknown'.

getter_union の型が unknown 型になるのは適切だろうか? 適切でないならば、どういう型が適切だろうか? Getter をどう修正すればよいだろうか?

deep な getter に型を付けてみる

どう deep にするかはいくつかバリエーションがあると思う。どれか 1 つを選んで実装してみよう

type Book = {
  title: string;
  author: Author;
  reputations: Reputation[];
};
declare const book: Book;

const deepGetter = makeDeepGetter(book);
// バリエーション1
const v32 = deepGetter("author", "name"); // const v32: string
const v33 = deepGetter("reputations", 0, "source"); // const v33: string | undefined
// バリエーション2
const v34 = deepGetter(["author", "name"]); // const v34: string
const v35 = deepGetter(["reputations", 0, "source"]); // const v35: string | undefined
// バリエーション3
const v36 = deepGetter("author.name"); // const v36: string
const v37 = deepGetter("reputations.0.source"); // const v37: string | undefined
// もしくは deepGetter(`reputations.${0}.source`);
// あるいは deepGetter("reputations[0].source"); など
// バリエーション4
const v38 = deepGetter("author")("name"); // const v38: string
const v39 = deepGetter("reputations")(0)("source"); // const v39: string | undefined

配列のlength

const v40 = getter_array("length");
//                       ^^^^^^^^  Argument of type 'string' is not assignable to parameter of type 'number'.
const v41 = getter_tuple("length");
//                       ^^^^^^^^  No overload matches this call.

配列型やタプル型の length を getter で取得できるようにするにはどうしたらよいだろうか?

@kurgm
Copy link
Author

kurgm commented Nov 23, 2022

ふと気づいちゃったけどこれ

type Getter<T extends object> =
  <P extends keyof T>(key: P) => T[P];

で終わりじゃないか()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment