TypeScript

TypeScriptのユーティリティ型入門:Partial・Pick・Omit・Record・Readonlyの使い分け

この記事でわかること

Partial・Pick・Omit・Record・Readonlyを会員管理の例で解説。更新項目の制限、実データの削除との違い、ネストやundefinedの注意点も確認します。

ユーティリティ型を使うと、一つの型から一覧用・登録用・更新用などの型を作れます。項目を何度も書く必要が減り、元の型を変更したときに用途別の型も追従できます。ただし型を変えても、実行時のオブジェクトの中身は変わりません。

オブジェクト型とジェネリクスの基礎を前提に、会員管理の例で使い分けを確認します。掲載コードはTypeScript 4.9.4で、strictexactOptionalPropertyTypesnoUncheckedIndexedAccess を有効にして検証しています。

用途に合わせて型を選ぶ

  • Pick<T, K> は、使うプロパティを選んだ型を作ります。
  • Omit<T, K> は、指定したプロパティを除いた型を作ります。
  • Partial<T> は、各プロパティを任意にします。
  • Record<K, V> は、キーと値の型を定めるオブジェクト型を作ります。
  • Readonly<T> は、その型を通じたプロパティへの再代入を禁止します。

これらはTypeScriptに組み込まれた型です。型を定義するための道具であり、入力の検証・値の加工・操作権限の確認は別の処理です。Utility Typesの公式解説

会員管理にユーティリティ型を適用する

会員データから用途別の型を作る

ファイル: src/members.ts

export type Member = {
  id: number;
  name: string;
  email: string;
  role: "reader" | "editor";
};

export type MemberSummary = Pick<Member, "id" | "name">;
export type NewMember = Omit<Member, "id">;
export type MemberPatch = Partial<Pick<Member, "name" | "email">>;

export const roleLabels: Record<Member["role"], string> = {
  reader: "閲覧者",
  editor: "編集者",
};

export function updateMember(member: Member, patch: MemberPatch): Member {
  return {
    ...member,
    name: patch.name ?? member.name,
    email: patch.email ?? member.email,
  };
}

export function getField<T, K extends keyof T>(object: T, key: K): T[K] {
  return object[key];
}

export function toSummary(member: Member): MemberSummary {
  return { id: member.id, name: member.name };
}

Partial<Member> だけではIDや権限も更新候補になるため、Pick で名前とメールを選んでから任意項目にしています。

updateMember() は更新を許した項目だけを読み取ります。TypeScriptの構造的な型付けでは、別の変数を経由すると余分なプロパティを持つ値も引数に渡せます。そのため、...patch ですべての項目をコピーする実装は、型だけではIDや権限の上書きを防ぎきれません。

この関数は入力を検証済みとする例です。APIやフォームからの値には、型・空文字・メール形式などの検証と、操作権限の確認が必要です。許可項目だけをコピーしても、これらの確認が不要になるわけではありません。

作成・更新・一覧表示を試す

ファイル: src/main.ts

import {
  getField,
  roleLabels,
  toSummary,
  updateMember,
} from "./members.js";
import type { Member, NewMember } from "./members.js";

const input: NewMember = {
  name: "あかり",
  email: "akari@example.com",
  role: "reader",
};
const member: Member = { id: 7, ...input };
const updated = updateMember(member, { name: "あかりさん" });
const snapshot: Readonly<Member> = updated;

console.log(getField(updated, "name")); // あかりさん
console.log(roleLabels[updated.role]); // 閲覧者
console.log(JSON.stringify(toSummary(snapshot))); // {"id":7,"name":"あかりさん"}
console.log(member.name); // あかり

更新関数は新しいオブジェクトを返すため、元の会員名はそのままです。Member["role"]"reader" | "editor" なので、roleLabels では両方の表示名が必要です。Record<string, string> のように任意の文字列をキーにすると、存在しないキーの読み取りも考慮しなければなりません。有限のキーの候補と任意の文字列キーを区別してください。

keyof Member"id" | "name" | "email" | "role" に相当します。getField()K extends keyof T は、そのオブジェクトの型にあるキーだけを許します。getField(updated, "name") の戻り値は string"missing" を渡すと型エラーです。keyofの公式解説

型から項目を除いても実データは消えない

const summary: MemberSummary = updated と代入しても、実際のオブジェクトには emailrole が残っています。公開する項目を減らす場合は、上の toSummary() のように必要な値だけを新しいオブジェクトに取り出します。

型を狭めることと、機密情報を取り除く処理は別です。PickOmit を指定しただけで、安全なAPIレスポンスへ変換されたとは判断できません。

PartialReadonly はネスト全体を変えない

ファイル: src/shallow.ts

type Profile = { settings: { theme: string } };

const empty: Partial<Profile> = {};
const partial: Partial<Profile> = { settings: { theme: "light" } };
const source: Profile = { settings: { theme: "light" } };
const view: Readonly<Profile> = source;

// settings自体の再代入は禁止されるが、その内部は変更できる。
view.settings.theme = "dark";

console.log(empty.settings); // undefined
console.log(partial.settings?.theme); // light
console.log(source.settings.theme); // dark

export {};

このオブジェクトの Partialsettings 自体を省略可能にしますが、指定した場合の theme は必須です。Readonlysettings の参照先まで自動で読み取り専用にしません。

Readonly は実行時の凍結処理ではありません。また、const snapshot: Readonly<Member> = updated はコピーを作らず、同じオブジェクトを参照します。別の変更可能な参照から書き換えれば、その変更は見えます。オブジェクトのreadonlyの説明

任意項目と undefined の違いを決める

今回の exactOptionalPropertyTypes: true では name?: string の省略は可能ですが、{ name: undefined } は型エラーです。明示的な undefined も値として認めたい場合は、型に追加します。exactOptionalPropertyTypesの公式説明

updateMember() は、項目がない場合に元の値を使います。Partial は空のオブジェクトも許すため、少なくとも1項目の変更が必要というルールがあるなら、それも検証してください。項目を消す操作が必要なら、null や専用の命令を使うなど、更新の意味を別途設計します。

実行方法と練習問題

空の作業用フォルダで npm init -ynpm install --save-dev typescript @types/node を実行します。Node.jsと型定義のバージョンを対応させ、上の3ファイルを src に保存します。

ファイル: tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "exactOptionalPropertyTypes": true,
    "noUncheckedIndexedAccess": true,
    "noEmitOnError": true,
    "rootDir": "src",
    "outDir": "dist",
    "lib": ["ES2022"],
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}
npx tsc -p tsconfig.json
node dist/main.js
node dist/shallow.js

練習として role"admin" を追加し、roleLabels の書き漏れを型チェックで確認します。次に、権限を変更する機能を追加する場合、更新用の型・許可項目をコピーする実装・実行時の権限確認がそれぞれ必要な理由を考えてください。

ユーティリティ型を選ぶ実務上の基準

登録・更新・表示の目的を分ける

登録時に必要な項目、更新を許可する項目、画面へ返す項目は同じとは限りません。PartialPickを使う前に、それぞれの操作で扱ってよいデータを決めると、型の意図が明確になります。

レスポンスは実際に組み立てる

秘密の項目を型から除外するだけでは、オブジェクトやJSONから消えません。返してよい値だけを新しいオブジェクトへ詰め直し、シリアライズした結果にも不要な情報が含まれないか確認します。

関連記事

TypeScriptとは・学習順・目的別の記事一覧へ戻る

y.
WRITTEN BY

y_ymo10

SEの部屋で、JavaScript・TypeScript・React.js・Next.jsの開発ノートを公開しています。

ほかの開発ノートを読む →