TypeScript(以下TS)の型の中で、あまり知られていない「ホモモーフィック(homomorphic)」なマップ型という概念があります。
この記事では
- ホモモーフィックなマップ型とは?
- どんな型がホモモーフィックで、どんな型が非ホモモーフィックなのか?
- メリットや使いどころ
- 実際の使用例
について、できるだけ丁寧に・分かりやすくご紹介していますので良かったら見てみてください!!
ホモモーフィックなマップ型ってなに?
TSでは、あるオブジェクト型 T
に対して、そのキー(keyof T
)を使って動的に新しい型を作ることができます。
たとえば、次のような型定義です。
type MyReadonlyT> = {
readonly [K in keyof T]: T[K]
}
これは「元の型の構造をベースに、すべてのプロパティに readonly
を付けた型」を定義しています。
このように、元の構造に忠実に変形された型のことを、TSでは「ホモモーフィック・マップ型(homomorphic mapped type)」と呼びます。
つまり、keyof T
を使って元の型をなぞるように処理する型ですね。
keyof T
を使って T
のキーをループしてるので、 T
との関連をちゃんと認識してくれる「ホモモーフィック」な型なのです。
keyof T
に限らず、元の構造を意識して動的に定義された型はホモモーフィックと見なされます。
非ホモモーフィックなマップ型とは?
一方で、元の型 T
のキーを使わずに、まったく別の型を定義すると、それは「非ホモモーフィック」な型になります。
TSは「これは T
を変形したものじゃなくて、ただ別物を作ったんだね」とみなして、特別な型処理(例:ユーティリティ型との連携など)をしません。
以下が非ホモモーフィックなマップ型の例になります。
たとえば、次のような例です。
type User = {
id: number;
name: string;
}
type ReadonlyUser = {
readonly id: number;
readonly name: string;
}
ReadonlyUser
は User
と同じ構造に見えますが、keyof User
を使っておらず、手動で定義された型なので、TSからは「構造の派生ではない(= 別物)」と見なされます。
つまり、元の型に依存せず完全に新しく定義された型は、TSから見ると「別物」と扱われるということですね。
ホモモーフィックの何がメリットなの?
ホモモーフィックなマップ型には、次のようなメリットがあります。
-
型の構造を保ちながら変形できる
元の型の構造を活かしたまま、プロパティの修飾(optional
、readonly
など)を加えることができます。 -
ユーティリティ型の自作がしやすい
Partial
やReadonly
のような型を、簡潔に定義できます。
// 各キーをoptional + nullに変換するユーティリティ型
type NullablePartialT> = {
[K in keyof T]?: T[K] | null
}
-
補完や型推論の精度が高い
構造が保たれているので、IDE(例:VSCode)でも型補完が正確になります。 -
変更に強く、保守性が高い
元の型T
に変更があっても型エラーで気づきやすく、再利用性もバッチリ!
実際の実装例
実際の実装例を見てみましょう!
たとえば、次の User 型があるとします。
type User = {
id: number;
name: string;
email: string;
}
このとき、id
は必須だけど、その他の項目(name, email)は省略可能にしたいケースってよくありますよね。
そんなときに役立つのが、以下のような型変換です。
type RequireIdT extends { id: unknown }> = {
id: T['id']
} & {
[K in Excludekeyof T, 'id'>]?: T[K]
}
この型では id
プロパティだけは必須に固定しつつ、それ以外のはオプショナルに変換するホモモーフィックな型定義です。
const user1: RequireIdUser> = {
id: 1
} // OK! nameやemailは省略可能
const user2: RequireIdUser> = {
id: 2,
name: '田中'
} // OK! emailは省略可能
const user3: RequireIdUser> = {
name: '田中',
email: '[email protected]'
} // NG! idは省略不可
// 型 '{ name: string; email: string; }' を型 'RequireId' に割り当てることはできません。
// プロパティ 'id' は型 '{ name: string; email: string; }' にありませんが、型 '{ id: number; }' では必須です。
このように、「一部だけ特別扱い+その他は動的に処理」も、ホモモーフィックな型なら柔軟に実現できます。
対して、静的な定義では。。。
type UserRequireId = {
readonly id: number;
readonly name: string;
readonly email: string;
}
より複雑なオブジェクトの場合だと冗長になりがちですし、やはりマップ型を使えば簡潔&再利用性もバッチリですね!
&
で型を合成するのと何が違うの?
型の拡張によく使われる &
(交差型)とホモモーフィックな型の違いも明確にしておきましょう。
例えば、次のような型定義です。
type WithTimestampT> = T & { createdAt: Date }
一見すると「元の型を拡張してる」ように見えますが、これはホモモーフィックな型ではありません。
なぜなら、これは T
の中身に応じて動的に変形しているわけではなく、単純に固定のプロパティ(createdAt)を追加しているだけだからです。
一方で、ホモモーフィックなマップ型は keyof T
を使っている場合、T
のキーそれぞれに対して何かしらの処理を施すというのがポイントです。
つまり、構造を「なぞる」ような変換ができるかどうかが、ホモモーフィックかどうかの分かれ道なんです!
具体例で比較してみよう!
ここでは「すべてのプロパティを readonly
にする」という同じ目的を、「静的に定義した型」と「ホモモーフィックなマップ型」で比較します。
&
による型合成(静的に定義した型)
type ReadonlyUser = {
readonly id: number
readonly name: string
}
この型は、元の型 User
のキーを手動で書き直して readonly
を付けたものです。keyof T
を使っていないので、TSから見ると「新しく作られた別の型」であり、ホモモーフィックとは見なされません。
ホモモーフィックな型変換
type MyReadonlyT> = {
readonly [K in keyof T]: T[K]
}
// const hoge: MyReadonly = ..
こちらは keyof T
を使って元の構造をループし、すべてのプロパティに readonly
を動的に適用しています。T
の構造をベースにした型変換であるため、TSはこれをホモモーフィックと判断します。
→ &
は「足し算」的な操作、ホモモーフィックは「構造変形」を伴う高度な操作が可能なのです!
いつホモモーフィック型を使うべき?それとも使わなくてもいい?
ここまでで、「ホモモーフィックな型は便利!」という話をしてきましたが、
だからといって、どんな場面でも使うべきというわけではありません。
以下に、使うべき場面と、あえて使わないほうがよいケースを整理して紹介します。
✅ ホモモーフィックな型を使うべきケース
■ 既存の型 T
に沿った変形をしたいとき
型を一括で変換したい場面では、マップ型で keyof T
を使うのが非常に効果的です。
各プロパティの修飾子(readonlyなど)も保ったまま柔軟に変形できます。
type MyOptionalT> = {
[K in keyof T]?: T[K]
}
■ Partial
, Pick
, Readonly
などのように、構造を維持しながら型を柔軟に変えたいとき
元の型構造をそのまま活かして、「部分的な適用」や「限定的な利用」ができるようにしたい場面などに有効です。
type MyReadonlyT> = {
readonly [K in keyof T]: T[K]
}
■ 一部プロパティを特別扱いしつつ、他を動的に処理したいとき
全体を変形するのではなく、一部はそのまま、他は動的に加工したいときに効果を発揮します。
type ReadonlyExceptIdT> = {
id: T['id']
} & {
[K in Excludekeyof T, 'id'>]: ReadonlyT[K]>
}
❌ ホモモーフィックを使わなくてもいいケース
■ Tの構造に依存せず、静的にプロパティを追加するだけのとき
型の構造を変形する必要がなく、ただ項目を追加するだけなら、ホモモーフィックである必要はありません。
type WithTimestampT> = T & { createdAt: Date }
■ ユニオン型やパターンマッチ型など、構造の一貫性が求められない場合
明確に分岐する構造(success/errorなど)では、マップ型の柔軟さよりも可読性重視で設計すべきです。
そのため、ホモモーフィックである必要はありません。
type Result =
| { status: 'success'; data: string }
| { status: 'error'; error: Error }
■ 再利用性が重視されない一発定義などのとき
再利用性や保守性よりも、単純明快で軽量な型のほうが優先される場合は、静的な定義で十分です。
type Simple = { name: string } & { age: number }
このように、ホモモーフィックなマップ型はとても便利な一面もありますが、目的や状況に応じて適材適所で使い分けることが大事です。
過度に抽象的な型変換を行うと、逆に型定義が読みにくくなることもあるため、可読性と保守性のバランスを考慮するのがベストです。
おわりに
ここまで読んでくださって、本当にありがとうございました!
TSで型を扱っていると、「新しく型を定義するべきか?」「既存の型をどう変形するか?」といった悩みにぶつかること、結構ありますよね。
私自身、日々TSを書いていても、
- 新しい型を一から定義するべき?
-
extends
で継承したほうがいい? - それともマップ型で動的に変形すべき?
…などなど、毎回のように迷ってしまうことがあります。
今回「ホモモーフィック型」という切り口で整理することで、
「構造をなぞるべきか?」「個別に定義するべきか?」の判断軸が少し見えてきたような気がします。
やっぱり、型って奥が深いですね……(*´-`)
あらためて、最後まで読んでくださりありがとうございます。
もし記事が参考になったら、「いいね」と「ストック」をしてもらえるとすごく励みになります!
また、内容に誤りや気になる点があれば、遠慮なくご指摘していただけると嬉しいです!
他にもいろいろな記事を投稿しているので、もしよかったら見てみてください!
ではでは!
参考
記事を執筆するにあたって、以下の資料を参考にさせていただきました。
先人たちの知見に感謝です!
Views: 0