ゴール:「T & U は“足し算された型”だ、と直感でわかるようになる」
ジェネリクスと intersection(交差型、&)を一緒に使うときのキーワードは、
「型を“混ぜる”のではなく、“足し算する”」
です。
union(|)は「どちらか一方」。
intersection(&)は「両方とも持っている」。
ここにジェネリクスを絡めると、
「どんな型が来ても、“これだけは必ず持っていてほしい”」
という“強い契約”を型で表現できるようになります。
intersection 型の直感:「A でもあり B でもある」
単純な intersection のイメージ
まずはジェネリクス抜きで、intersection 型そのもののイメージから。
type HasId = { id: number };
type HasName = { name: string };
type User = HasId & HasName;
const u: User = {
id: 1,
name: "Taro",
};
TypeScriptHasId & HasName は、
「id も持っていて、name も持っている型」
です。
つまり intersection は、
「A か B」ではなく
「A でもあり B でもある」
という“両方満たす型”を作る演算子です。
セキュリティの感覚で言うと、
「このオブジェクトは“ID を持つ”という条件も、“名前を持つ”という条件も両方満たしている」
という“条件の AND”に近いです。
union との違いを一度はっきり見ておく
同じ 2 つの型で union を作るとこうなります。
type UserUnion = HasId | HasName;
const a: UserUnion = { id: 1 }; // OK
const b: UserUnion = { name: "Taro" }; // OK
TypeScriptHasId | HasName は、
「id だけ持っているか、name だけ持っているか、両方持っていてもいい」
という“どれか一つ以上”の世界です。
intersection(&)は「両方必須」、
union(|)は「どれか一つでOK」。
この違いを頭の片隅に置いたまま、ジェネリクスと組み合わせていきます。
ジェネリクス+intersection:T に「これも足しておく」
「T に必ず id を足す」ジェネリック型
例えば、「どんなオブジェクトでもいいけど、必ず id を持たせたい」というとき。
type WithId<T> = T & { id: number };
type UserBase = { name: string };
type User = WithId<UserBase>;
// User は { name: string } & { id: number } → { name: string; id: number }
const u: User = {
id: 1,
name: "Taro",
};
TypeScriptここでやっていることは、
「T が何であっても、{ id: number } を足した型を作る」
という操作です。
WithId<T> の T に別の型を入れても同じです。
type ProductBase = { title: string; price: number };
type Product = WithId<ProductBase>;
// { title: string; price: number; id: number }
TypeScript重要なのは、
「ジェネリクスの T は“ベースの型”、intersection の { id: number } は“必須で足す型”」
という役割分担になっていることです。
関数で使うと「引数に“条件を足す”」イメージになる
同じ発想を関数に持ち込むと、こうなります。
function attachId<T>(obj: T, id: number): T & { id: number } {
return { ...obj, id };
}
const user = attachId({ name: "Taro" }, 1);
// user: { name: string } & { id: number } → { name: string; id: number }
const product = attachId({ title: "Book", price: 1000 }, 10);
// { title: string; price: number; id: number }
TypeScriptここでは、
引数 obj の型が T
戻り値の型が T & { id: number }
になっています。
つまり、
「どんなオブジェクトを渡しても、id を足したものを返す」
という契約を、型で正確に表現できています。
セキュリティ的に言えば、
「元の情報に“追跡用の ID”を必ず付与する」
ような処理を、型レベルで保証しているイメージです。
intersection で「権限を足す」イメージを持つ
権限付きユーザーを intersection で表現する
セキュリティっぽい例で考えてみます。
type User = { id: number; name: string };
type AdminPermission = { canDeleteUser: boolean };
type AdminUser = User & AdminPermission;
const admin: AdminUser = {
id: 1,
name: "Root",
canDeleteUser: true,
};
TypeScriptAdminUser は、
「User でもあり、AdminPermission でもある」
という型です。
これをジェネリクスにすると、こうなります。
type WithPermission<TPermission> = User & TPermission;
type AuditPermission = { canViewLogs: boolean };
type AuditUser = WithPermission<AuditPermission>;
// { id: number; name: string; canViewLogs: boolean }
TypeScriptここでのポイントは、
「User というベースに、TPermission という“追加の権限”を intersection で足している」
という構造です。
ジェネリクス+intersection を使うと、
「ベースとなるアイデンティティ」と
「追加の権限・属性」
を、きれいに分離しつつ合成できます。
関数で「権限付きオブジェクト」を返す
関数にすると、さらに実務っぽくなります。
function withPermission<TPermission>(
user: User,
permission: TPermission
): User & TPermission {
return { ...user, ...permission };
}
const u: User = { id: 1, name: "Taro" };
const adminUser = withPermission(u, { canDeleteUser: true });
// { id: number; name: string; canDeleteUser: boolean }
const auditUser = withPermission(u, { canViewLogs: true });
// { id: number; name: string; canViewLogs: boolean }
TypeScriptここでは、
「User に、任意の権限オブジェクトを“足したもの”を返す」
という処理を、User & TPermission で表現しています。
セキュリティの観点では、
「ユーザー情報と権限情報を、型レベルで一貫して扱う」
という設計になります。
intersection +制約(extends)で「T は最低限これを持つ」
「T はオブジェクトであること」を前提に intersection する
intersection は「足し算」なので、
T がオブジェクトでないと意味が薄くなります。
そこで、制約を組み合わせます。
function addTimestamp<T extends object>(
obj: T
): T & { createdAt: Date } {
return { ...obj, createdAt: new Date() };
}
const log = addTimestamp({ message: "hello" });
// { message: string; createdAt: Date }
TypeScriptT extends object によって、
「T はオブジェクトでなければならない」
という条件をかけています。
そのうえで T & { createdAt: Date } を返すことで、
「どんなオブジェクトでもいいけど、createdAt を必ず足す」
という関数になります。
セキュリティ的には、
「ログやイベントに必ずタイムスタンプを付与する」
ような処理を、型で保証しているイメージです。
「T は必ず id を持つ」+「さらに何かを足す」
制約と intersection を組み合わせると、
「最低限の条件」+「追加情報」という構造も作れます。
function tagAsSensitive<T extends { id: number }>(
obj: T
): T & { sensitive: true } {
return { ...obj, sensitive: true };
}
const record = tagAsSensitive({ id: 1, name: "secret" });
// { id: number; name: string; sensitive: true }
// tagAsSensitive({ name: "no-id" }); // エラー:id がない
TypeScriptここでは、
T extends { id: number } が「最低限の条件」
戻り値の T & { sensitive: true } が「追加情報」
です。
「ID を持つものだけを“機密扱い”にできる」
という制約を、型で表現できています。
intersection でやりがちな勘違いと注意点
「& だから、両方のメソッドが全部使える」は半分正しい
例えば、
type A = { a: () => void };
type B = { b: () => void };
type AB = A & B;
const v: AB = {
a() {},
b() {},
};
v.a(); // OK
v.b(); // OK
TypeScriptこのように、単純なオブジェクト型同士なら、
「両方のプロパティ・メソッドが全部使える」
という理解でほぼ問題ありません。
ただし、union と絡むと話がややこしくなります。
type X = { x: string } | { y: number };
type Y = { z: boolean };
type XY = X & Y;
TypeScriptこの場合の XY は、
「{ x: string; z: boolean } か { y: number; z: boolean }」
のような形になり、
直感的には少し分かりづらくなります。
初心者のうちは、
「まずは“素直なオブジェクト型同士の &”から慣れる」
くらいで十分です。
「intersection は“マージ”ではなく“両方満たす型”」
& を「マージ」とだけ覚えると、
ときどき挙動に戸惑います。
正確には、
「両方の型を同時に満たす型」
です。
オブジェクト型同士なら「マージ」に見えますが、
プリミティブ型同士だと「ありえない型」になることもあります。
type A = string & number; // 実質 never に近い
TypeScriptジェネリクスと組み合わせるときは、
「T に“足して意味がある型”を & する」
という使い方に絞ると、変な罠にハマりにくくなります。
まとめ:ジェネリクスと intersection を自分の言葉で説明すると
最後に、あなた自身の言葉でこう整理してみてください。
intersection(&)は、
「A か B」ではなく「A でもあり B でもある」型を作る演算子。
ジェネリクスと組み合わせると、
T & { id: number }のように「どんな T にも id を足す」User & TPermissionのように「ベースの型に権限を足す」- 制約付きで
T extends objectにして「オブジェクトにだけ何かを足す」
といった、“条件を足し算する”設計ができる。
まずは次の 3 つを、自分の手で書いてみてください。
type WithId<T> = T & { id: number };
function attachId<T>(obj: T, id: number): T & { id: number } { return { ...obj, id }; }
function addTimestamp<T extends object>(obj: T): T & { createdAt: Date } { return { ...obj, createdAt: new Date() }; }
TypeScriptそして、いろいろなオブジェクトを渡してみて、
「元の型に“これが必ず足される”」
という感覚を、型と挙動の両方で味わってみてください。
そこで、
「ジェネリクスは“ベースの型の穴”、intersection は“そこに条件や属性を足す演算”」
と感じられたら、
ジェネリクスと intersection の基礎はもうしっかり掴めています。
