【TypeScript】ユニオン型を型を厳密に保ったまま別のユニオン型に変換する


## はじめに

色の名前`"red" | "green" | "blue"`を対応するカラーコード`"ff0000" | "00ff00" | "0000ff"`に変換する関数`getHex`を考えます。

```ts
type ColorName = "red" | "green" | "blue";

const hexByColorName = {
  red: "ff0000",
  green: "00ff00",
  blue: "0000ff",
} as const satisfies Record<ColorName, string>;

const getHex1 = (colorName: ColorName) => hexByColorName[colorName];
```

では`getHex1`を使ってみます。

```ts
const hex1 = getHex1("red");
// hex1の型: "ff0000" | "00ff00" | "0000ff"
```

`hex1`の型は`"ff0000" | "00ff00" | "0000ff"`になってしまいます。
`getHex1`関数には直接`"red"`を渡しており他の値が渡る可能性はないので型は`"ff0000"`となってほしいのが人情ですよね。
これを改善します。

## 結論

以下のようにすると期待通りになります。

```ts
const getHex2 = <T extends ColorName>(colorName: T) => hexByColorName[colorName];

const hex2 = getHex2("red");
// hex2の型: "ff0000"
```

引数にGenericsを使うようにしました。
これによって`getHex2`に渡した値`"red"`は、文字列リテラルのユニオン型`"ff0000" | "00ff00" | "0000ff"`ではなく厳密に文字列リテラル`"red"`として扱われるようになります。

参考までに、型と実装を分けて書くと以下のようになります。

```ts
type HexByColorName = typeof hexByColorName;

type GetHex2 = <T extends ColorName>(colorName: T) => HexByColorName[T];
const getHex2: GetHex2 = (colorName) => hexByColorName[colorName];
```

## 【応用】引数にnull,undefinedを渡せるようにする

応用です。
引数として`null`, `undefined`が渡された場合は変換せずそのまま`null`, `undefined`を返すという要件を追加した関数`getHexNullable`を作ってみます。
つまり以下のような感じです。

```ts
const nonNullHex = getHexNullable("red" as ColorName);
// const nonNullHex: "ff0000" | "00ff00" | "0000ff"

const nullableHex = getHexNullable("red" as ColorName | null);
// const nullableHex: "ff0000" | "00ff00" | "0000ff" | null

const nll = getHexNullable(null)
// const nll: null
```

そもそもnullなどは利用側でハンドリングするべきな気もしますが例ということで大目にみてくれると助かります。

では実装です。

```ts
type GetHexNullable = <T extends ColorName | null | undefined>(
  colorName: T
) => T extends keyof HexByColorName ? HexByColorName[T] : T;

const getHexNullable: GetHexNullable = (colorName) =>
  (colorName && hexByColorName[colorName]) as any;
```

実装はこれで完了ですが、簡単に解説です。
まずは型定義の返り値の部分について。

```ts
T extends keyof HexByColorName ? HexByColorName[T] : T
```

これはユニオン型と`extends`（Conditional Type）を組み合わせる使い方です。
TypeScriptのドキュメントではこれをDistributive Conditional Types（分配条件型）と呼んでいます。

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

https://typescriptbook.jp/reference/type-reuse/union-distribution#distributive-conditional-types

この意味はユニオン型`T`のそれぞれの要素を別々に考えてみると分かりやすいです。

```ts
type HexByColorName = {
  readonly red: "ff0000";
  readonly green: "00ff00";
  readonly blue: "0000ff";
}

// T extends keyof HexByColorName ? HexByColorName[T] : T
"red" extends "red" | "green" | "blue" ? HexByColorName["red"] : "red"
 null extends "red" | "green" | "blue" ? HexByColorName[null]  : null
```

上のようなイメージです（TypeScriptとしては正しくありません）。
`"red"`は`extends "red" | "green" | "blue"`を満たすので前半の`? HexByColorName["red"]`の分岐に入り、
`null`は`extends "red" | "green" | "blue"`を満たさないので後半の`: null`の分岐に入ります。

型の解説はここまでで、関数の実装部分についてもひとつ補足です。

```ts
const getHexNullable: GetHexNullable = (colorName) =>
  (colorName && hexByColorName[colorName]) as any;
```

返り値に`as any`を使っています。
型定義の方を厳密にした影響で`as any`をつけないとエラーになってしまうからですが、ここまでして帰り値の型を厳密にするかについては場合によると思います。

## 参考

Conditional Typeや`as any`の扱いなどについて、以下の記事がとても参考になりました。

https://blog.uhy.ooo/entry/2020-08-31/dont-fear-ts/

## まとめ

なるべく型を厳密にしたいときはGenericsを使おう。