🧅

ユニオン型をkeyにもvalueにも持ったオブジェクト型(?)を作りたい

2024/01/17に公開2

謎タイトルだけど伝わってほしい。
TypeScriptと型バトルしたときに得た知見を共有します。

やりたいこと

以下のようにUnionKeyをkeyにもvalueにも持つユニオンなオブジェクト型を作りたくなりました。

type UnionKey = "aaa" | "bbb";
type UnionItem = {
  id: "aaa";
  aaa: Record<string, unknown>;
} | {
  id: "bbb";
  bbb: Record<string, unknown>;
}

手を動かしてみる

最初はとりあえずMapped Typesを使ってみましたがうまくいかず…

type UnionItem = {
  id: UnionKey;
} & {
  [K in UnionKey]: Record<string, unknown>;
}

const item: UnionItem = {
  id: "aaa",
  aaa: { hoge: "fuga" },
  bbb: { piyo: "payo" }, // id:"aaa"なのにbbbがないといけない
}

Mapped Typesの箇所をOptionalにすればまあまあうまくいきますが、使う側でいろいろ気を使わないといけないので微妙でした。

type UnionItem = {
  id: UnionKey;
} & {
  [K in UnionKey]?: Record<string, unknown>;
}

const item: UnionItem = {
  id: "aaa",
  aaa: { hoge: "fuga" }, // OK
}

// 何かしらの処理
const consoleUnionItem = (item: UnionItem[UnionKey]) => 
  console.log(item);

if (item.id === "aaa") {
  // コンパイルエラー
  consoleUnionItem(item.aaa);
  // 通りはするがid:"aaa"ならitem.aaaは保証されていたい
  consoleUnionItem(item.aaa ?? { hoge: "fuga default" });
}

できたもの

いろいろ書き直したりChatGPTに聞いたりしていたところ、中間にUnionItemMapを作成することで要件を満たすことができました。

type UnionItemMap<T extends UnionKey> = {
  id: T
} & {
  [K in T]: Record<string, unknown>;
};
type UnionItem = {
  [K in UnionKey]: UnionItemMap<K>;
}[UnionKey];

UnionItemMapは、渡されたジェネリクスに合ったオブジェクト型を返す型です。
展開すると以下のようになります。

type UnionItemMap<"aaa"> = {
  id: "aaa";
  aaa: Record<string, unknown>;
}

type UnionItemMap<"bbb"> = {
  id: "bbb";
  bbb: Record<string, unknown>;
} 

最後にUnionKeyをkeyに持つオブジェクトに対してユニオン型が分配され、求めていた型を作ることができました!

type UnionItem = {
  aaa: UnionItemMap<"aaa">;
  bbb: UnionItemMap<"bbb">;
}["aaa" | "bbb"];
// ↓
type UnionItem = UnionItemMap<"aaa"> | UnionItemMap<"bbb">;
// ↓
type UnionItem = {
  id: "aaa";
  aaa: Record<string, unknown>;
} | {
  id: "bbb";
  bbb: Record<string, unknown>;
};

さいごに

今回はなんとかなりましたが、まだまだTypeScript知らないことだらけなのでどこかでちゃんと勉強したいです。TypeScriptとの型バトルは続く…

参考

https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types

Discussion

nap5nap5

この定義を達成したいと考えたユースケースをつかめていないのですが、やりたいことみて、ぼくもちょっとやってみました。

template-literal-types使えば途中は省けるんじゃないかと思いました。

import type { IsEqual, Simplify } from 'type-fest'

type UnionKey = "aaa" | "bbb";

type Morph<K extends UnionKey> = Simplify<{
  id: K,
} & {
    [P in `${K}`]: Record<string, unknown>
  }>

type SumMorph = Morph<"aaa"> | Morph<"bbb">

type UnionItem = {
  id: "aaa";
  aaa: Record<string, unknown>;
} | {
  id: "bbb";
  bbb: Record<string, unknown>;
}

type Judge = IsEqual<SumMorph, UnionItem>

demo code.

https://codesandbox.io/p/devbox/patient-breeze-z74wh8?file=%2Fsrc%2Findex.ts

shikachiishikachii

ありがとうございます!
確かにこちらの方が記述がシンプルになって嬉しいですね!
参考にさせていただきます🙌