TypeScriptの実践的な型設計:ユニオン・satisfies・unknownの使い分け
TypeScriptの実践的な型設計をコードで解説。判別可能なユニオンによる状態管理、neverの分岐漏れ検出、satisfiesによる設定の型検査、unknownを使ったAPI応答の検証を学べます。TypeScript 4.9以降に対応。
TypeScriptで実務のバグを減らすには、状態ごとの違いを判別可能なユニオンで表し、設定値をsatisfiesで検査し、外部データをunknownから検証するのが有効です。この記事では、記事の取得と表示を例に、型チェックで確認することと実行時に確認することを分けて解説します。
基本的な型と関数を学んだ方向けです。コードはTypeScript 4.9.4のstrictモードで検証しています。satisfiesとin演算子による未知のプロパティの絞り込みを使うため、TypeScript 4.9以降が必要です。通信処理はブラウザーなどのfetchを使える環境を前提とします。
1. オプショナルプロパティだけでは不正な状態が残る
まず、取得する記事の型を共通のファイルに置きます。
ファイル:src/lib/post.ts
export type Post = {
id: string;
title: string;
};読み込み状態を別々のフラグや省略可能なプロパティで表すと、画面の仕様に合わない組み合わせまで許可することがあります。
ファイル:src/examples/loose-state.ts
import type { Post } from "../lib/post";
type LooseState = {
loading: boolean;
data?: Post;
error?: string;
};
// データとエラーを同時に持っていても、型チェックを通る
const ambiguous: LooseState = {
loading: false,
data: { id: "post-1", title: "型設計の基本" },
error: "読み込みに失敗しました",
};
console.log(ambiguous);画面側の条件を増やす前に、許可する状態を定義しましょう。以下では、待機中・読み込み中・成功・失敗の4状態を排他的に扱います。再取得中も古い記事を表示する仕様なら、それを表す状態と必要なデータを追加してください。
2. 判別可能なユニオンで状態とデータを結びつける
共通のstatusプロパティに異なる文字列リテラルを指定すると、条件分岐に応じて利用できるプロパティが絞り込まれます。
ファイル:src/lib/load-state.ts
import type { Post } from "./post";
export type LoadState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: Post }
| { status: "error"; message: string };
export function getLabel(state: LoadState): string {
switch (state.status) {
case "idle":
return "記事を選んでください";
case "loading":
return "読み込み中";
case "success":
return state.data.title;
case "error":
return state.message;
default:
return assertNever(state);
}
}
function assertNever(value: never): never {
throw new Error("未対応の状態です");
}successの分岐ではdataが存在すると判断されるため、data!のような非nullアサーションは不要です。statusだけがsuccessでdataがない値は、この型に代入できません。
neverは分岐漏れを見つけるために使います。LoadStateにcancelledを追加して分岐を追加し忘れると、未処理の値をassertNever()へ渡す箇所が型エラーになります。外部から届く値の正しさを保証するものではないため、入力の検証は別に必要です。
参考:TypeScript Handbook — Discriminated unions
3. satisfiesで設定の形を確かめる
設定オブジェクトには、「必要なキーを揃えたいが、各値の使いやすい型は残したい」という場面があります。
ファイル:src/lib/routes.ts
type PageName = "home" | "articles";
type Route = string | { path: string; requiresAuth: boolean };
export const routes = {
home: "/",
articles: { path: "/articles", requiresAuth: false },
} satisfies Record<PageName, Route>;
console.log(routes.home.toUpperCase());
console.log(routes.articles.path.toUpperCase());satisfiesは、値が指定した型を満たすかをコンパイル時に確認します。この例ではhomeを文字列として、articlesをオブジェクトとして扱えます。オブジェクトリテラルのキーをarticelsと誤記するような間違いも検出できます。
一方、const routes: Record<PageName, Route>と型注釈を付けると、各プロパティはRouteとして扱われ、文字列用の操作の前に絞り込みが必要になります。どちらを使うかは、値を後からどのように扱いたいかで決めます。
satisfiesは、すべての値を必ず文字列リテラル型のまま保持する機能ではありません。実行時の検証やオブジェクトの凍結も行いません。リテラル型と読み取り専用の型が必要なら、as constの役割も分けて考えてください。
参考:TypeScript 4.9 — The satisfies Operator
4. APIの応答はunknownから検証する
APIの型定義を書いても、実際の応答が定義どおりになるとは限りません。as Postと書くだけでは通信結果を検証できません。
ファイル:src/lib/fetch-post.ts
import type { Post } from "./post";
export function parsePost(value: unknown): Post {
if (
typeof value !== "object" ||
value === null ||
Array.isArray(value)
) {
throw new Error("記事データはオブジェクトである必要があります");
}
if (
!("id" in value) || typeof value.id !== "string" ||
!("title" in value) || typeof value.title !== "string"
) {
throw new Error("記事のidとtitleは文字列である必要があります");
}
return { id: value.id, title: value.title };
}
export async function fetchPost(url: string): Promise<Post> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`記事の取得に失敗しました: ${response.status}`);
}
const value: unknown = await response.json();
return parsePost(value);
}unknownとして受け取ると、検証せずにプロパティを使うことを防げます。typeof、nullチェック、in演算子で値を絞り込み、確認できたプロパティだけを持つ新しいオブジェクトを返しています。
この関数が確認するのはidとtitleが文字列であることです。空文字の禁止や長さ制限は別途追加してください。また、inは継承したプロパティも調べます。この例はresponse.json()から得たJSONデータを想定しています。任意のJavaScriptオブジェクトを検査し、自身のプロパティに限定したい場合は、所有プロパティの確認も必要です。
fetch()はHTTPの404や500だけでは失敗として拒否されないため、response.okを確認します。通信エラー、JSONの解析エラー、parsePost()の検証エラーは呼び出し元へ伝わります。画面で利用する際はtry・catchで失敗を受け取り、LoadStateのerror状態へ変換します。
参考:TypeScript 4.9 — Unlisted Property Narrowing、MDN — Using the Fetch API
5. 実務での使い分け
- 状態に応じたデータの有無は、判別可能なユニオンで表します。
switchの分岐漏れは、neverで検出します。- コード内で作る設定のキーと値は、
satisfiesで型を確認します。 - APIやJSONなどの外部入力は、
unknownで受け取り、実行時に検証します。
まずはstrictモードを有効にし、外部データの受け取りと画面状態の定義から見直すと導入しやすくなります。型だけで業務上のすべての条件を表そうとせず、実行時の検証と組み合わせてください。
6. ファイルを保存して試す
ここまでの5ファイルを記事中のパスどおりに保存します。次の例では通信を行わず、検証と表示ラベルの連携を試せます。
ファイル:src/examples/practical-types.ts
import { parsePost } from "../lib/fetch-post";
import { getLabel, type LoadState } from "../lib/load-state";
function toLoadState(input: unknown): LoadState {
try {
return { status: "success", data: parsePost(input) };
} catch (error: unknown) {
return {
status: "error",
message: error instanceof Error ? error.message : "不明なエラーです",
};
}
}
console.log(getLabel(toLoadState({ id: "1", title: "型設計の基本" })));
// 型設計の基本
console.log(getLabel(toLoadState({ id: 1, title: "型設計の基本" })));
// 記事のidとtitleは文字列である必要がありますTypeScriptがインストール済みのプロジェクトで、次のコマンドを実行します。
npx tsc --strict --target ES2020 --module commonjs --lib ES2020,DOM --skipLibCheck --outDir .article-demo src/examples/practical-types.ts src/examples/loose-state.ts src/lib/routes.ts
node .article-demo/examples/practical-types.jsこのコマンドはtsconfig.jsonを読み込まず、指定したファイルと、そのファイルから読み込む依存先をコンパイルします。実行例はNode.jsで動かせます。プロジェクトのpackage.jsonに"type": "module"を設定している場合は、別の学習用ディレクトリで実行するか、出力形式をプロジェクトのES Modules設定へ合わせてください。
よくある疑問
satisfiesを付ければAPIの応答も安全になりますか?
なりません。satisfiesはコンパイル時の型検査です。APIの応答はunknownとして受け取り、実行時に内容を検証する必要があります。
asは使わないほうがよいですか?
常に禁止する必要はありません。ただし、型アサーションは実行時の検証を追加しません。外部データをasで目的の型として扱う前に、その型だと保証できる根拠を確認しましょう。
ユニオンはReact専用の書き方ですか?
いいえ。TypeScriptの型の仕組みなので、React以外でも使えます。フォーム送信、ファイル解析など、状態によって必要なデータが変わる処理に適用できます。
型設計の改善をどこから始めるか
失敗が多い境界を先に扱う
入力検証の漏れ、読み込み状態の矛盾、設定の書き間違いなど、実際の不具合を一つ選びます。そのケースを型とテストでどう検出するかを決めると、複雑な型を増やすこと自体が目的になりません。
レビューで確かめる観点
新しい状態を追加したときの分岐漏れ、外部入力が欠けたときの処理、失敗後の状態を確認します。型チェックが通ることと、利用者へ正しい結果を返すことの両方を確かめてください。
関連する開発ノート
Googleの優先するニュース提供元にSEの部屋を追加
Googleで、いつも読みたい情報源を選べます。登録可否はGoogleの画面で確認できます。
候補にSEの部屋が表示されない場合は、まだ追加できません。