TypeScript 型設計は型を増やすより境界を決める
TypeScriptの型設計で悩む場面は、実務ではかなり多いです。anyを消したい。APIレスポンスの型をどう置くか迷う。Reactのpropsが肥大化する。こうした悩みは、単に型注釈を増やしても解決しません。
結論から言うと、TypeScriptの型は責務で分けます。「外部から来るデータ」「業務上の意味を持つデータ」「画面に渡すデータ」を分けると、保守しやすくなります。
この記事では、React/TypeScriptの実務でanyを減らす考え方を解説します。目的は、変更に強い型を作ることです。型の文法説明ではなく、レビューや改修で困りにくい設計判断を中心に整理します。
TypeScriptの型設計は「正しく書く」だけでは足りない
まず、TypeScriptを導入していても型安全とは限りません。例えば、APIレスポンスをanyで受け取るケースがあります。その値をそのまま画面に渡すと、型チェックはほとんど効きません。
async function fetchUser() {
const response = await fetch("/api/user");
const user: any = await response.json();
return user;
}
このコードは一見シンプルです。しかし、user.nameが存在するかは分かりません。statusがどんな値を取るかも読み取れません。nullableかどうかも曖昧です。そのため、後続のコンポーネントや関数で暗黙の前提が増えます。
一方で、すべての値に細かい型を付ければよいわけでもありません。型が複雑すぎると、修正箇所を追いにくくなります。つまり、実務で重要なのは「型の量」ではなく「型を置く場所」です。
anyを減らす前にunknownで境界を明確にする
次に、anyを減らすときは、いきなり完璧な型を作らない方が現実的です。外部から来る値は、まずunknownとして扱います。そうすると、境界がはっきりします。
async function fetchJson(url: string): Promise<unknown> {
const response = await fetch(url);
return response.json();
}
unknownは、そのままではプロパティにアクセスできません。そのため、値を使う前に型を絞り込む必要があります。TypeScript公式のNarrowingでも、この考え方が説明されています。条件分岐などによって型を具体化する方法です。
例えば、最低限のtype guardを用意すると、外部データをアプリケーション内部へ入れる前に確認できます。
type ApiUser = {
id: string;
name: string;
email: string | null;
};
function isApiUser(value: unknown): value is ApiUser {
if (typeof value !== "object" || value === null) {
return false;
}
const user = value as Record<string, unknown>;
return (
typeof user.id === "string" &&
typeof user.name === "string" &&
(typeof user.email === "string" || user.email === null)
);
}
なお、実務ではZodなどのバリデーションライブラリを使うこともあります。重要なのは、TypeScriptの型だけで外部データの正しさを保証できると考えないことです。API、localStorage、URLパラメータ、外部SDKの戻り値は境界です。実行時チェックが必要な場所として扱います。
TypeScriptのAPIレスポンス型設計では画面用の型を分ける
型設計で特に差が出るのが、APIレスポンス型の扱いです。レスポンスの型をそのままReactコンポーネントのpropsに使うと、変更が広がりやすくなります。バックエンドの変更が画面全体へ伝わるためです。
type ApiUser = {
id: string;
first_name: string;
last_name: string;
status: "active" | "inactive" | "deleted";
email: string | null;
created_at: string;
};
type UserListItem = {
id: string;
displayName: string;
statusLabel: string;
emailText: string;
};
function toUserListItem(user: ApiUser): UserListItem {
return {
id: user.id,
displayName: `${user.last_name} ${user.first_name}`,
statusLabel: user.status === "active" ? "利用中" : "停止中",
emailText: user.email ?? "未登録",
};
}
このように、APIレスポンス型と画面用の型を分けると、表示ロジックの責務が明確になります。また、APIの命名がsnake_caseでも問題ありません。画面側ではdisplayNameやemailTextのように、意味を持つ名前で扱えます。
ただし、すべての画面で専用型を作る必要はありません。単純に一覧表示するだけなら、APIレスポンス型でも十分な場合があります。変更が入りやすい画面では、変換層を置く価値が高くなります。表示用の加工が多い画面や、複数APIを合成する画面も同じです。
interfaceとtypeは役割で使い分ける
よくある質問として、interfaceとtypeのどちらを使うべきかがあります。結論としては、チーム内でルールをそろえることが最優先です。そのうえで、拡張されるオブジェクト構造にはinterfaceを使います。合成やUnionを扱う型にはtypeを使うと整理しやすくなります。
interface User {
id: string;
name: string;
}
type UserStatus = "active" | "inactive" | "deleted";
type UserWithStatus = User & {
status: UserStatus;
};
実際には、どちらでも書ける場面が多いです。そのため、レビューでは「interfaceかtypeか」よりも、「その型が何を表しているか」「変更理由が混ざっていないか」を見た方が効果的です。
Utility Typesは意図を表すために使う
さらに、Utility Typesは型設計を実務に合わせるときに便利です。TypeScript公式のUtility Typesには、代表的な型が整理されています。Pick、Omit、Partial、Required、Readonlyなどです。
例えば、更新フォームでは全フィールドを送らないことがあります。一部だけを送るケースです。このとき、APIの入力型を明示すると、意図が読みやすくなります。
type User = {
id: string;
name: string;
email: string;
role: "admin" | "member";
createdAt: string;
};
type UpdateUserInput = Partial<Pick<User, "name" | "email">>;
この型は「nameとemailだけ更新対象にできる」という意図を表しています。さらに「どちらも任意である」ことも示しています。ただし、Utility Typesを重ねすぎると読みにくくなります。レビューで毎回読み解きが必要なら、専用の型名を付ける方が保守しやすいです。
satisfiesで定数と型チェックを両立する
定数オブジェクトを扱うときは、satisfiesが役立つ場面があります。TypeScript 4.9のリリースノートでは、satisfies operatorが紹介されています。
例えば、画面で使うステータス表示を定義するとき、値の推論を保ちながら構造をチェックできます。
type UserStatus = "active" | "inactive" | "deleted";
const userStatusLabels = {
active: "利用中",
inactive: "停止中",
deleted: "削除済み",
} satisfies Record<UserStatus, string>;
この書き方にすると、UserStatusに新しい値を追加したとき、ラベル定義の漏れに気づきやすくなります。一方で、asによる型アサーションは注意が必要です。多用すると、コンパイラに「これは正しい」と言い切る形になります。必要な場面はありますが、型チェックを弱めていないか確認しましょう。
React TypeScriptではpropsの責務を小さくする
React TypeScriptの実務では、propsの型がコンポーネント設計を表します。propsが増えすぎる場合、型だけの問題ではありません。コンポーネントの責務が大きすぎる可能性があります。
type UserCardProps = {
user: UserListItem;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
canEdit: boolean;
};
この程度であれば読みやすいです。ところが、検索条件、権限、表示モード、API取得状態、イベントハンドラがすべてpropsに入ると、変更理由が混ざります。
そのため、ContainerとPresentationを分ける設計が有効です。Custom Hookに取得処理を寄せる方法もあります。表示コンポーネントには加工済みの値を渡します。型設計は、コンポーネント分割の結果として読みやすくなるのが理想です。
TypeScript 型設計で避けたいアンチパターン
なお、TypeScriptのコードレビューでは、次のような書き方を見つけたら設計を見直すタイミングです。
| アンチパターン | 起きやすい問題 | 見直すポイント |
|---|---|---|
| anyでAPIレスポンスを受ける | 後続の型チェックが効かない | unknown、type guard、バリデーションを検討する |
| 型アサーションを多用する | コンパイラの警告を握りつぶす | 値の確認、型の絞り込み、satisfiesを使う |
| 巨大な共通型を使い回す | 変更の影響範囲が広がる | 用途別の型に分ける |
| API型を画面propsに直結する | バックエンド変更がUIへ伝播する | 変換関数で画面用モデルを作る |
| Utility Typesを重ねすぎる | 型の意図が読みにくい | 型名を付けて意味を表す |
もちろん、これらを完全に禁止する必要はありません。小さな管理画面や短期の検証では、過度な型設計が開発速度を落とすこともあります。ただし、長く保守するプロダクトでは注意が必要です。境界を曖昧にした型が、後から大きな負債になります。
実務レビューで見るTypeScript 型設計のチェックリスト
- 外部データをanyで内部に入れていないか?
- APIレスポンス型と画面用の型が必要以上に結合していないか?
- null、undefined、空文字、未取得状態の扱いが明確か?
- asによる型アサーションが、本当に必要な場所だけにあるか?
- propsの型からコンポーネントの責務が読み取れるか?
- Utility Typesを使った型が、レビューで説明しなくても読めるか?
- ドメイン上あり得ない状態を型で表現できているか?
実務では、このチェックリストをすべて満たすよりも、変更が多い場所から整える方が効果的です。まずはAPI境界から見直します。次にフォーム、一覧と詳細の表示モデル、権限制御の型を確認すると改善効果が出やすくなります。
TypeScript 型設計でよくある質問
TypeScriptではanyを絶対に使ってはいけませんか?
絶対禁止ではありません。ただし、anyは型チェックを弱めます。そのため、使う場所を限定するべきです。外部ライブラリの型が不足している場合や、段階的な移行では使うこともあります。重要なのは、境界に閉じ込めて内部へ広げないことです。
APIレスポンス型は自動生成すべきですか?
OpenAPIなどから型を自動生成できるなら、有力な選択肢です。一方で、自動生成された型をそのまま画面全体で使うと密結合になることがあります。そのため、API境界では生成型を使います。画面側では用途に応じた型へ変換する設計が現実的です。
Reactのprops型はコンポーネント内でよいですか?
小さなコンポーネントなら同じファイル内で問題ありません。複数のコンポーネントやページで共有する型なら、責務が分かる場所に切り出します。ただし、何でも共通typesファイルに置くと依存が分かりにくくなります。まずは利用範囲を狭く保つのがおすすめです。
まとめ:TypeScript 型設計は境界を決める設計である
型設計では、型を細かく書くこと自体が目的ではありません。anyを減らすことは大切です。さらに、APIレスポンス、ドメイン、画面表示の境界を明確にします。そうすることで、変更に強いコードになります。
まずは、外部データをunknownとして扱います。次に、APIレスポンス型と画面用型を分けます。さらに、React propsの責務を小さくします。この3つから見直すだけでも、レビューや改修のしやすさは大きく変わります。
React/TypeScriptの実務では、型設計、状態管理、コンポーネント分割を切り離して考えることはできません。bluenaの採用情報では、経験や志向に合わせた案件相談も行っています。今のTypeScript経験を次の環境でどう活かすか。そこから一度整理してみてください。
一度カジュアル面談をしませんか?
株式会社bluenaは「高還元」と「伴走支援」を両立したSES企業です。単価の81〜86%を還元する報酬体系と、専任サポーターによる隔週1on1で、エンジニアが納得できるキャリアを実現します。
まとまっていなくてもOK——まずは現在地を聞かせてください。
カジュアル面談ですので、お気軽にお聞かせください。





