TypeScriptで型安全な開発をしていると、「柔軟なキーを許容しつつ、特定の型構造を持たせたい」といった場面にしばしば直面します。そんなときによく使われるのが Record<string, unknown>
という型定義です。これは「任意の文字列キーに対して、任意の値(ただし unknown
)」という汎用的なマッピングを意味します。
しかし、Record<string, unknown>
に特定の構造(たとえば、id: number
のようなプロパティ)をマージして使いたい場合、どう型定義を設計すればいいのでしょうか?単純な交差型(&
)を使うと意図しない挙動になるケースもあるため、慎重な設計が求められます。
本記事では、そんなニッチながら重要な型定義テクニックについて解説していきます。
1. Record<string, unknown>の意味と用途
まずは Record<string, unknown>
の基本を押さえておきましょう。これはTypeScriptのユーティリティ型の一つで、すべてのキーがstring
であり、対応する値がunknown
であるようなオブジェクト型です。
type MyObject = Record<string, unknown>;
const obj: MyObject = {
name: "Alice",
age: 30,
active: true,
};
この型の主な用途は以下のような場合です。
- JSONのようなキー不定なデータ構造を扱いたいとき
- 任意の追加プロパティを受け入れるAPI設計をしたいとき
- 値の型を一旦
unknown
にして、安全に絞り込む戦略を取りたいとき
とはいえ、ただの Record<string, unknown>
では静的な型保証が弱くなります。特に、ある程度の既知プロパティ(例:id
, type
)が存在する構造体を扱いたい場合、これだけでは心許ないのです。
2. Recordと交差型をそのまま使ったときの落とし穴
最も直感的な方法は、次のように &
を使って型をマージすることです。
type Base = Record<string, unknown>;
type WithId = { id: number };
type Merged = Base & WithId;
この方法は一見正しく動作しそうですが、実際には以下のような問題が起こり得ます。
id
プロパティにunknown
型が被ってしまい、型推論で曖昧になる- IDEによっては
id: number
が型エラー扱いになる(特にVSCodeでの補完が不安定になる)
なぜこうなるかというと、Record<string, unknown>
は「すべてのキーが string
で値が unknown
」という定義のため、id: number
を追加しても「id
は unknown
でなければならない」という型制約が残るのです。つまり、交差型が矛盾してしまうのです。
3. 上書きマージをするためのMapped Typeの応用
この問題を解決するには、「既知の型を優先して Record<string, unknown>
を上書きする」ような仕組みが必要です。そのためには、Mapped Type(マップ型)とConditional Type(条件付き型)を組み合わせてカスタム型を作成します。
以下は、そのためのユーティリティ型の例です。
type MergeRecord<T extends Record<string, unknown>, U extends Record<string, unknown>> = {
[K in keyof T | keyof U]: K extends keyof U
? U[K]
: K extends keyof T
? T[K]
: never;
};
この MergeRecord
は、T
に U
をマージし、U
のプロパティが優先されるように設計されています。
使用例:
type Base = Record<string, unknown>;
type Specific = { id: number; name: string };
type Result = MergeRecord<Base, Specific>;
const obj: Result = {
id: 123,
name: "test",
other: true,
};
補足:型衝突を避けるための設計ポイント
- 上書きされることを明示する:設計時に「どちらが勝つか」を意図的に制御する
- 特定プロパティの制限を強制したい場合は、
Partial<T>
やPick<T, K>
と組み合わせると良い
4. Object型全体に既定構造をミックスするパターン
もし「不特定多数のキー + いくつかの必須プロパティ」という構造を一般化して扱いたい場合、次のような構造が役立ちます。
type WithBaseFields<T extends Record<string, unknown>> = {
id: number;
type: string;
} & T;
このようにすると、次のような利用ができます。
type CustomPayload = WithBaseFields<{
customFlag: boolean;
extra?: string;
}>;
const payload: CustomPayload = {
id: 1,
type: "update",
customFlag: true,
};
これにより、「特定の共通フィールド + カスタム定義の拡張」という柔軟なオブジェクト型が作れます。
応用例:APIのリクエストボディ定義
type ApiRequest<T extends Record<string, unknown>> = {
headers: {
authorization: string;
};
body: WithBaseFields<T>;
};
このような構造は、REST API設計において特に効果を発揮します。
5. Utility Typesとの組み合わせで型安全を高める
さらに、TypeScript標準のユーティリティ型と組み合わせることで、型安全をより強化できます。
以下は具体例です。
Omit<T, K>
:既知プロパティを削除してマージ時の衝突を回避Partial<T>
:オプショナルな拡張を許可Required<T>
:オプショナルフィールドの強制Pick<T, K>
:対象キーのみ抽出
type SafeMerge<T, U> = Omit<T, keyof U> & U;
この SafeMerge
は、T
の中で U
とキーが重複するものを除去し、U
を上書きしてマージする型です。
type Base = { id: unknown; flag: boolean };
type Override = { id: number };
type Final = SafeMerge<Base, Override>; // id: number になる
実戦Tip
Record<string, unknown>
の代わりにRecord<string, any>
を使うと、柔軟性が高くなるが型安全性が下がるunknown
の型ガードを併用して、実行時チェックも組み込むと堅牢な実装が可能
6. 実装と型定義の分離で保守性を高める設計戦略
TypeScriptで複雑なオブジェクト型を扱う際には、実装と型定義を明確に分けておくことが重要です。特に Record<string, unknown>
をベースにした構造は、プロジェクトが大規模化すると可読性と保守性に大きく影響します。
分離の基本戦略
types/
ディレクトリを作成し、共通型をモジュール化- プリミティブな
Record<string, unknown>
はインターフェースでラップ - 特定機能専用のユーティリティ型(例:
MergeRecord
)を個別定義
// types/common.ts
export type AnyObject = Record<string, unknown>;
export type MergeWithId<T extends AnyObject> = {
id: number;
} & T;
このようにすることで、コンポーネントやユースケースごとの型定義が明快になり、IDE補完や静的解析も正確に機能するようになります。
まとめ
TypeScriptの Record<string, unknown>
は柔軟な型定義の基盤として非常に強力ですが、単体では型安全性や拡張性に限界があります。本記事では、より安全かつ柔軟に特定型をマージするテクニックを紹介しました。
ポイントをおさらいすると:
- 単純な交差型でのマージは衝突の元になる
Mapped Type
を使って優先的なマージが可能Omit
やSafeMerge
などユーティリティ型を組み合わせて安全に拡張- 実装と型定義は分離して保守性を意識した設計を心がける
これらの工夫をすることで、より強固で拡張可能な型設計が可能になります。大型TypeScriptプロジェクトでは、こうした細かい設計方針が長期的な品質につながります。
参考
- TypeScript Utility Typeshttps://www.typescriptlang.org/docs/handbook/utility-types.html
- Effective TypeScript(著:Dan Vanderkam)https://effectivetypescript.com/
- Qiita:Record<string, unknown>とその周辺https://qiita.com/Takepepe/items/b55ef983c738a9675da5