Open5

TypeScript で { foo: "A", bar: "B" } as const したオブジェクトから、obj.foo で "A" | "B" 型の値を得る方法

misukenmisuken

obj の値はリテラル型にしておきたいので as const を使用するが、値を参照して使用する際には推論に利用する関係で、その obj から取れ得るリテラル型全ての共用体型が欲しい場合にどうすると一番スマートなインタフェースになるか。

// これだとダメ
const obj = { foo: "A", bar: "B" } as const;
func(obj.foo); // obj.foo は "A"
// これもダメ
const obj = { foo: "A", bar: "B" };
func(obj.foo); // obj.foo は string
// こうすれば良いけど書くのが面倒
const obj = { foo: "A", bar: "B" } as const;
func(obj.foo as typeof obj[keyof typeof obj]);
misukenmisuken

obj を生成するときに関数通して作成して、以下のようなオブジェクトを返し

{
  (key: keyof T): T[keyof T],
  foo: "A",
  bar: "B",
}

こんな感じにできるとスマートそうというのが今の所の結論。

obj.foo; // "A"
obj.bar; // "B"
func(obj("foo")); // obj("foo") は "A" | "B"

keyof で取得しても関数は影響しないし、アロー関数はオブジェクトとしても余計なプロパティ持って悪さすることもなさそうなので、一つの選択肢としては悪くなさそうだけどどうなんだろう?

// R は "A" | "B"
type R = keyof {
  (key: keyof T): T[keyof T],
  foo: "A",
  bar: "B",
};
misukenmisuken

これでできたことはできたが、これで何か問題が起きるパターンってあるのだろうか?

function generate<T extends {[P in keyof T]: V }, V extends string | number | boolean>(obj: T): { (key: keyof T): T[keyof T] } & Readonly<T> {
  const func: any = (key: keyof T): T[keyof T] => obj[key];
  for (const key in obj) {
    func[key] = obj[key];
  }
  return func;
}

//  ((key: "foo" | "bar") => "A" | "B") & Readonly<{ foo: "A"; bar: "B" }>
const obj = generate({
  foo: "A",
  bar: "B",
});

obj.foo;   // "A"
obj.bar;   // "B"
obj("foo");// "A" | "B"
obj("bar");// "A" | "B"

playground

misukenmisuken

目的のオブジェクトを作ることはできたが、オブジェクトを受け取る関数に渡そうとすると、関数部分が型として一致しない問題が発覚。

オブジェクトを spread すると関数部分が抜け落ちるので、通常のオブジェクトとなり型が通るようになる。

function generate<T extends { [P in keyof T]: V }, V extends string | number | boolean>(obj: T): { (key: keyof T): T[keyof T] } & Readonly<T> {
  const func: any = (key: keyof T): T[keyof T] => obj[key];
  for (let key in obj) {
    func[key] = obj[key];
  }
  return func;
}

const obj = generate({ foo: "A", bar: "B" });

declare function f1(obj: { [P in string]: string }): void;
declare function f2<V extends string>(obj: { [P in string]: V }): void;

// Argument of type '((key: "foo" | "bar") => "A" | "B") & Readonly<{ foo: "A"; bar: "B"; }>' is not assignable to parameter of type '{ [x: string]: string; }'.
//   Index signature is missing in type '((key: "foo" | "bar") => "A" | "B") & Readonly<{ foo: "A"; bar: "B"; }>'.(2345)
f1(obj);
f2(obj);

// OK
f1({ ...obj });
f2({ ...obj });

playgroun

ただ、利用用途的に若干不便なので、関数側で関数オブジェクトが渡されたとしても関数部分を無視して型チェックできるように工夫する必要があった。

function generate<T extends { [P in keyof T]: V }, V extends string | number | boolean>(obj: T): { (key: keyof T): T[keyof T] } & Readonly<T> {
  const func: any = (key: keyof T): T[keyof T] => obj[key];
  for (let key in obj) {
    func[key] = obj[key];
  }
  return func;
}

const obj = generate({ foo: "A", bar: "B" });

export type IgnoreFunction<T, U> = T & ({ [P in keyof T]?: T[P] } extends U ? T : U);

declare function f1<T>(obj: IgnoreFunction<T, { [P in string]: string }>): void;
declare function f2<T, V extends string>(obj: IgnoreFunction<T, { [P in string]: V }>): void;

// OK
f1(obj);
f2(obj);

// OK
f1({ ...obj });
f2({ ...obj });

playground

misukenmisuken

T & は省略して問題なさそう。

export type IgnoreFunction<T, U> = ({ [P in keyof T]?: T[P] } extends U ? T : U);