TypeScript | 関数・クラス・ジェネリクス:ジェネリクス基礎 - ジェネリクスとintersection

TypeScript TypeScript
スポンサーリンク

ゴール:「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",
};
TypeScript

HasId & 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
TypeScript

HasId | 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,
};
TypeScript

AdminUser は、

「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 }
TypeScript

T 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 の基礎はもうしっかり掴めています。

タイトルとURLをコピーしました