メインコンテンツへスキップ
ブログ一覧に戻る
Web制作・運用

TypeScriptのベストプラクティス:モダンな開発チームのための10のヒント

2024年4月10日
最終更新: 2026年10月8日
7分で読めます

この記事でわかること

TypeScriptを効果的に活用するためのベストプラクティスを解説。型安全性を高めつつ、開発効率を向上させる方法をご紹介します。

2026年10月8日にTypeScript Handbookの本文を再確認した。ベストプラクティスは型注釈を増やすことではなく、strictな境界、unknownからの安全な絞り込み、null/undefinedの扱い、公開APIの型、設定と生成物、実行時入力の検証、コンパイルとテストの責任を揃えることにある。型アサーションやanyを通過点として乱用せず、型が表す不変条件と実行時の事実を別にレビューする。

TypeScriptを導入しても、APIから来る値や利用者の入力が自動的に安全になるわけではありません。型を付ける場所と、実際の値を調べる場所を分けると、コードレビューでも確認すべき点が明確になります。以下の11枠はそれぞれ独立した教材です。同名の型や変数を、そのまま一つのファイルへ連結する構成ではありません。

30秒で要点

  • 内部の値は推論と具体的な型で扱い、外部の不明な値はunknownから検査します。
  • strictを有効にして、空配列・欠損・nullなどの分岐を型に残します。asで消して終わりにしません。
  • 型の整合性と実行結果の両方を確認します。ReadonlyやOmitを付けても、実データを凍結・削除したことにはなりません。
外部unknownを実行時に検査してから型を絞り込み、型検査と値の検証を分ける図
著者作成。型アサーションで検査を省略せず、正常値と拒否する値を実際に確認する流れです。

1. anyで不明点を隠さず、受け付ける値を絞る

文字列か数値の配列の長さを扱うなら、両者を明示できます。anyでは、数値やnullを渡した間違いまで検査から外してしまいます。

export function processData(data: string | number[]): number {
  return data.length;
}

const textLength = processData("abc"); // 3
const itemCount = processData([10, 20]); // 2

この型は呼び出し側もTypeScriptで検査される内部契約です。ネットワークから来たJSONを、この型だと決めつけて渡す用途には使いません。基本型の公式説明は、unionで共通に使える操作と、絞り込みが必要な操作を区別しています。

2. 明らかな型は推論に任せる

初期値が明らかな変数には、型名を重ねなくても推論が働きます。一方、公開する関数の入出力は、利用者への契約として明記する価値があります。

const userName = "Alice";
let retryCount = 0;
retryCount += 1;
export const label = `${userName}:${retryCount}`; // Alice:1

推論に任せることと、入力の形式を検査しないことは別です。初期値を空配列にする場合や、複数の状態を取り得る場合には、意図する型を補います。

3. オブジェクトと選択肢を読み取れる形にする

オブジェクトの形はinterfaceでもtypeでも表せます。常に一方が優れていると決めず、チーム内の規則と、unionや交差型が必要かで選びます。

export interface User { id: number; name: string }
export type Status = "draft" | "published";
export type NamedUser = User & { status: Status };

export function describe(user: NamedUser): string {
  return `${user.name}:${user.status}`;
}

文字列を自由入力にするより、Statusの選択肢を限定すると、未処理の状態を探しやすくなります。ただし外部から届いた文字列がこの集合に入るかは、別の実行時検査が必要です。

4. strictを起点に、設定を記録する

これは独立した教材プロジェクトの設定例です。既存プロジェクトでは対象ファイルや実行環境と合わせ、段階的に適用します。noUncheckedIndexedAccessはここで明示的に追加しています。

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "noEmit": true
  },
  "include": ["src/**/*.ts"]
}

strictのリファレンスでは、複数の厳格な検査をまとめて有効にすると説明されています。更新により検査が追加されることもあるため、TypeScriptの版とロックファイル、検査コマンドを記録します。警告を消すためだけに全体を緩めず、発生箇所の欠損処理を確認します。

5. typeofで分岐してから操作する

数値と文字列で使える操作が異なる場合は、条件分岐が必要です。条件を読めば、どの値を受け付けるかも分かります。

export function processValue(value: string | number): string {
  if (typeof value === "string") return value.toUpperCase();
  return value.toFixed(2);
}

この例では"ab"を"AB"に、3を"3.00"にします。小数の丸めを業務上の金額計算へそのまま流用する例ではありません。

6. カスタム型ガードの中身を検証する

型述語は、関数の実装が正しいという保証ではありません。既知のunionに対するinだけの判定を、不明なJSONの検証へ流用しないようにします。ここではunknownを受け、null・配列・必須項目・有限の速度を調べます。

export type Car = { kind: "car"; speed: number };

export function isCar(value: unknown): value is Car {
  return typeof value === "object" && value !== null &&
    !Array.isArray(value) && "kind" in value && value.kind === "car" &&
    "speed" in value && typeof value.speed === "number" &&
    Number.isFinite(value.speed) && value.speed >= 0;
}

export function readSpeed(value: unknown): number {
  if (!isCar(value)) throw new Error("invalid car");
  return value.speed;
}

速度0は許可し、負数・文字列・NaN・nullは拒否します。絞り込みの公式資料の仕組みを使う一方、受け付ける条件自体は、この教材で決めた仕様です。

7. ジェネリクスでも空を残す

要素の型を保ちながら先頭を返す例です。配列が空ならundefinedなので、戻り値を単にTと書いてはいけません。

export function first<T>(items: readonly T[]): T | undefined {
  return items[0];
}

export const firstNumber = first([10, 20]); // number | undefined
export const emptyValue = first<string>([]); // string | undefined、実値はundefined

必ず1件以上あることを別の型で表す設計も可能ですが、この関数の契約は空を許します。呼び出し側が欠損時の表示や次の操作を決めます。

8. optional chainingとnull合体で欠損を扱う

?.は途中の欠損を扱い、??はnullまたはundefinedのときだけ代わりの値を選びます。0や空文字まで置き換えたいのかを区別します。

type User = { profile?: { nickname?: string; score?: number } };

export function profileLabel(user: User | null): string {
  const name = user?.profile?.nickname ?? "未設定";
  const score = user?.profile?.score ?? 10;
  return `${name}:${score}`;
}

{ profile: { nickname: "", score: 0 } }なら結果は":0"です。空文字を禁止したい場合は、入力条件として別に検査します。

9. asの代わりに外部値を確認する

as stringは文字列への変換でも検証でもありません。フォームやJSONなどから来た値を扱う境界では、実際に種類と内容を確認します。

export function readTitle(input: unknown): string {
  if (typeof input !== "string" || !input.trim()) {
    throw new Error("title must be a non-empty string");
  }
  return input.trim();
}

型アサーションはコンパイル時に取り除かれます。必要な場面で使う場合も、「どの検査や外部契約を根拠に、この型だと判断したか」を説明できるようにします。

10. Utility Typesとkeyofを用途に合わせる

次は型の派生、部分更新、キーの制約をまとめた例です。PickやOmitの型を付けるだけで、元オブジェクトから個人情報が消えるわけではありません。公開用の値は明示的に作り直します。

type User = { id: number; name: string; email: string };
export type PublicUser = Pick<User, "id" | "name">;
export type WithoutEmail = Omit<User, "email">;
export type Patch = Partial<Pick<User, "name">>;
export type Snapshot = Readonly<PublicUser>;

export function toPublic(user: User): PublicUser {
  return { id: user.id, name: user.name };
}
export function rename(user: User, patch: Patch): User {
  return { ...user, name: patch.name ?? user.name };
}
export function getProperty<T, K extends keyof T>(object: T, key: K): T[K] {
  return object[key];
}

Utility Typesの説明は型の変換です。Readonlyはその型を通じた代入を制限しますが、オブジェクトを実行時に凍結する操作ではありません。また、Partialの入力を外部APIから受ける場合には、想定外のキーも拒否する実装が必要です。

11. 共有する型をモジュールに分ける

通常のモジュールから必要な型だけを公開します。呼び出し側ではimport typeを使うと、型だけの依存であることを表せます。

// domain-types.ts
export interface User { id: number; name: string }
export type UserId = User["id"];
export type Result<T> = { ok: true; value: T } | { ok: false; message: string };

利用側でokを判定してからvalueを使えば、成功と失敗の取り違えを減らせます。型ファイルを大量に分けること自体を目的にせず、変更の責任範囲が読み取れる単位にします。

レビューで使う確認表

表が収まらない場合は、横にスクロールできます

対象通す例拒否・分岐する例検査の役割
内部関数unionに含まれる値union外の呼び出しコンパイル時
外部データkindがcar、速度0null、負数、NaN、型違い実行時
配列先頭要素あり空ならundefined両方
欠損明示された0nullなら既定値実行時
公開用データidとnameemailを返さない実行時の構築

教材では各枠を独立してコンパイルし、該当する関数へ正常値と不正値を渡して確認します。型の検査が通ることと、業務仕様・安全性・外部APIの実動作が正しいことを、同じ結果として報告しないようにします。

よくある質問(FAQ)