1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【TS憲章】なぜこの型が存在するのかを説明できるコードへ

Last updated at Posted at 2026-01-04

はじめに

TypeScriptの価値は「型を付けること」ではありません。
「間違った設計をコンパイル時、不可能にすること」 です。

にもかかわらず、現場では次のようなコードが平然とレビューを通過します。

  • any や as T による型逃げ
  • boolean / optional / null に責務を押し込んだ状態管理による型の破綻
  • APIレスポンスを「信じる」前提の設計
  • Props や引数がどこで更新されるか分からない型定義

これらは一見「動く」ため問題が表面化しません。しかし、レビュー・保守・引き継ぎのタイミングで必ず破綻します。

本記事では、
「TypeScriptで“やってはいけない設計”を確実に潰す」 ことに特化し、

  • なぜその型がダメなのか
  • どこで設計が破綻しているのか
  • どう書けばレビューで説明できるのか

を 設計・レビュー視点でキャッチするのが目的です。

なお、現場ルールとして即座に使える TypeScript開発憲章 も併せて掲載しています。レビュー基準やチーム規約を整備したい場合は、そちらも参照してください。

対象読者

これは TypeScript 入門記事ではありません。「なんとなく雰囲気で書いて、設計が弱いコード」を排除するための虎の巻です。初学者の方で網羅的に学習したい方は以下をご参照ください。

目次

  • ケース別アンチパターン(即死順)

    • 状態管理(boolean / null / optional の罠、Unionでの排他制御)

    • API境界の型処理(unknown / as T / 共通 fetch 化)

    • Props設計(optionalの濫用、readonlyの使い方)

    • 関数引数設計(順序依存・意味不明な型名)

    • 配列・オブジェクトの型(readonly の意味と使いどころ)

  • TypeScript 開発憲章(レビュー虎の巻)

    • 状態管理 / 関数設計 / 境界層 / readonly

  • まとめ(共通原則)

  • AIレビュー観点

  • 最後に

  • 引用・参考リンク

ケース別アンチパターン(即死順)

※前提として、本記事の間違いのご指摘、より良い実装したなどがあればコメントくださると助かります。🙇

ここでは、保守改修などで致命傷になりがちな下記順番で解説していきたいと思います。

  1. 状態管理(最優先・事故率No.1)
  2. API境界(後で腐る)
  3. Props設計(レビュー事故)
  4. 関数引数(将来破壊)
  5. 配列・オブジェクト(設計思想のズレ)

1-1. 状態管理(最頻出)

本セクション以降で、赤帯でアンチパターン緑帯でベスプラパターンを紹介します。

本節の虎の巻は以下です。

  • boolean / optional / null で状態を表現した時点で設計負け
  • 「同時に成立してはいけない状態」は Union 以外禁止

1-1-1.❌ booleanフラグの罠/安易なnull許容

type State = {
  isLoading: boolean;
  isError: boolean;
  data?: User;
  error?: Error;
};
// ❌Loading中かつSuccess状態が可能 → 実行時バグのためNG

// ❌ 状態を null に押し込めている
const [user, setUser] = useState<UserState | null>(null);

✅ Union型で排他的に

union型で排他制御を行う
switch文など併用し、状態管理をハンドリングできる

 
// ありえない状態が型レベルで排除される
type UserState =
  | { status: "loading" }
  | { status: "ready"; user: User }
  | { status: "error"; error: Error };  

const [state, setState] = useState<UserState>({ status: "loading" });

// --- loadingの分岐網羅で使用すると、更に処理を追いやすい
function UserProfile({ state }: { state: UserState }) {
  switch (state.status) {
    case "loading":
      return <Spinner />;
    case "ready":
        // 確実にuserがあることが担保されるので、?を使用しないで済み、バグの温床を防止
      return <div>{state.user.name}</div>; 
    case "error":
      return <Error message={state.error.message} />;
  }
}

1-2. API境界の型処理

本節の虎の巻は以下です。

❌ unknown を UI に持ち込むな
❌ API境界で握れないなら設計ミス

❌ APIごとに専用関数を書く

async function fetchUser(url: string) {
  // User専用の処理専用 → DRY違反
  const json = (await fetch(url).then(r => r.json())) as User; 
  // as句を使用 ⇨ ❌TSの型安全を放棄し、データの安全性を正しく担保していない。
  return { status: "success", data: json };
   // ...
}
✅ ジェネリクス関数やunknownを使用し、型安全な汎用関数化に昇華

全API対応で保守範囲を広げない
レビュー時に「この data はどの状態で安全?」という質問が不要にする

※zod等スキーマライブラリなどで型アサーションを完全に排した実装に更新しました。

共通関数
import { z } from "zod";

export type AsyncData<T> =
  | { readonly status: "loading" }
  | { readonly status: "success"; readonly data: T }
  | { readonly status: "error" };

export async function fetchJson<S extends z.ZodSchema>(
  url: string,
  schema: S
): Promise<AsyncData<z.infer<S>>> {
  try {
    const res = await fetch(url);
    if (!res.ok) {
      return { status: "error" };
    }

    // API境界では unknown で受ける
    const raw: unknown = await res.json();

    // zod だけを信頼する
    const parsed = schema.safeParse(raw);
    if (!parsed.success) {
      return { status: "error" };
    }

    return { status: "success", data: parsed.data };
  } catch {
    return { status: "error" };
  }
}
呼び出し側
// <!-- domain層 -->
import { z } from "zod";

// ※ 簡略化のため`schema`と`domain`を同一ファイルに配置
export const UserSchema = z.object({
  id: z.number(),
  name: z.string().min(1, "名前は必須です"),
  email: z.string().email("無効なメール形式です"),
});

export type User = z.infer<typeof UserSchema>;
// <!-- Index.tsx -->
/**
 * ユーザー情報編集フォームコンポーネント。
 */
export const UserEditForm = ({ userId }: { userId: number }) => {
  const { register, reset, handleSubmit } = useForm<User>({
    resolver: zodResolver(UserSchema),
  });

  const [result, setResult] = useState<AsyncData<User>>({ status: "loading" });

  useEffect(() => {
    let isMounted = true;

    const load = async () => {
      setResult({ status: "loading" });
      const res = await userRepository.getById(userId);

      if (!isMounted) return;

      if (res.status === "success") {
        reset(res.data); // Readonly -> Mutable (RHF内部でコピー)
      }
      setResult(res);
    };

    load();
    return () => { isMounted = false; };
  }, [userId, reset]);

  // result.status で早期return
  if (result.status === "loading") return <p>Loading...</p>;
  if (result.status === "error") return <p>エラーが発生しました</p>;
  // success
  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register("name")} />
      <button type="submit">更新</button>
    </form>
  );
};

1-3. Props設計(React)

本節の虎の巻は下記です。

optional(?)の濫用

1-3-1.❌ optionalだらけで何でもあり

interface ButtonProps {
  text?: string;
  onClick?: () => void;
  href?: string;
  disabled?: boolean;
}

// 呼び出し①
<Button onClick={fn} href="/link" />  // 実行までエラーに気づかない
✅ ブラウザ標準を継承し、堅牢性を向上する。

ComponentPropsWithRefや、ComponentPropsWithoutRefを前提にする。
車輪の再発明をしない。

import type { ComponentPropsWithRef, ReactNode } from "react";

type ButtonVariantProps =
  | ({
      // UI独自性は別途で定義
      variant: "button";
      icon?: ReactNode;
    } & ComponentPropsWithRef<"button">)

  | ({
      variant: "icon";
      icon: ReactNode;
      "aria-label": string;
    } & ComponentPropsWithRef<"button">);

// 使用例
// 1. 通常ボタン(中身は children で自由に渡す)
<Button variant="button">送信</Button>

// 2. アイコンボタン(中身は渡さず、iconプロパティとlabelで完結させる)
<Button variant="icon" icon={<PlusIcon />} aria-label="追加" />

1-4. 関数の引数設計

❌ 順序ミスの罠

呼び出し側で順序ミスが起きる
値の意味が呼び出しから読み取れない
オプション追加のたびに破壊的変更が発生する


// パターン①:引数が多すぎる
function createUser(
  id: number,
  name: string,
  email: string,
  age: number,
  role: string,
  department: string
) {}

// ①型が合っていれば致命的なミスでも通る
// salesとadminが逆順
createUser(1, "田中", "email@test.com", 30, "sales", "admin");
✅ オブジェクト(竹案)
// パターン①-①
type CreateUserParams = {
  id: number;
  name: string;
  email: string;
  age: number;
  role: string;
  department: string;
};

// 順序依存を排除
const params: CreateUserParams = {
  id: 1,
  name: "田中",
  email: "email@test.com",
  age: 30,
  role: "admin",
  department: "sales", 
};

createUser(params);

ただし、この書き方では、TSの強みを半分しか使っていない です。更に良くするには↓

✅ オブジェクト化 + 型制限(松案)
// パターン①-②

// as constで定数化で厳格化
// <!-- const.ts -->
const ROLE = {
  ADMIN: "admin",
  USER: "user",
} as const;

const DEPARTMENT = {
  SALES: "sales",
  HR: "hr",
} as const;

// -----------------------------------------------------------------
// <!-- Index.tsx -->
type CreateUserParams = {
  id: number;
  name: string;
  email: string;
  age: number;
  role: Role;
  department: Department;
};

createUser({
  id: 1,
  name: "田中",
  email: "email@test.com",
  age: 30,
  //  ✅API / DB / UI 間で値ブレが起きない
  role: ROLE.ADMIN,  // ✅ typo不可
  department: DEPARTMENT.SALES, // ✅ 値の意味が明確

});

❌ 型名が意味不明の罠

// パターン②
function calculatePrice(
// 「型は付いているが、仕様は消えている」
  base: number, // number だけでは 意味が一切分からない
  discount: number, // 割合なのか金額なのか不明(単位ミスの元)
  tax: number 
): Price {
  return base * (1 - discount) * (1 + tax);
}
✅ 意味ある型名にする。
// パターン②
type Price = number;
 // レビューで「discount は率だよね?」が不要
type DiscountRate = number; /** 0.0〜1.0 */  // ←JSDocで想定値を入れておくと親切設計
type TaxRate = number;      /** 0.0〜1.0 */ 


function calculatePrice(
  base: Price,
  discount: DiscountRate,
  tax: TaxRate
): Price {
  return base * (1 - discount) * (1 + tax);
}

// -----------------------------------------------------------
<!-- Index.tsx -->
calculatePrice(1000, 0.1, 0.08); // 割引10%、消費税8%
// hover時に、中身が明確になる。

1-5. 配列 & オブジェクトの型

readonly は「安全装置」ではない
「このデータは変わらない」という“仕様表明”である

1-5-1.❌ readonlyのアンチパターン

readonly を「なんとなく安全そう」で付けている or つけていない。
更新ロジックと設計思想が衝突している

type User = {
  id: number; // ❌ 変更されるべきでない ID が可変
  tags: string[]; // ❌ どこで push されるか分からない
};

const user: User = { id: 1, tags: ["admin"] };
user.id = 2;         // 意図しないIDの書き換えが発生する
user.tags.push("a"); // 元のデータを壊してしまう(React等の状態管理でバグの元)

この例の問題は「コピー更新が悪い」のではなく、
**「更新前提のデータ構造に readonly を付けている」点です。
あくまでも型が嘘をついている点に着目し、コードリーディング時に脳がバグるのを回避します。

完全な不変性と「コピー更新」の徹底✅️

ReactのStateやドメインモデルでは、**「既存のオブジェクトは一切書き換えない(イミュータブル)」**という設計が最も堅牢です。

type User = {
  readonly id: number;      // 業務的に絶対不変
  readonly name: string;
  readonly tags: readonly string[]; // 配列の中身も不変
};

const user: User = { id: 1, name: "Alice", tags: ["admin"] };

// ✅ 更新は「新しい状態の生成」で行う
const updated: User = {
  ...user, 
  name: "Bob",
  tags: [...user.tags, "editor"] // コピーして新配列を作成
};

設計破綻チェック

🚨 チェック NG条件
状態は Union か boolean / null / optional
ありえない状態は排除されているか loading & success
Props / 引数は不変か 子で再代入
「読む型」と「更新型」が分離されているか 同一型で混在

境界層チェック

🔒 チェック NG条件
外部入力は unknown か res.json() 直返し
as T は境界のみか UI / Domain に存在
unknown は1箇所に集約されているか components側に漏れあり

型表現の質

✍️ チェック NG条件
Generics に目的と制約(extends)がある とりあえず <T>
keyof / typeof は制約表現か key: string
命名が責務を語っている data / value
brand型をと入れられている idがそのままnumber型

※brand型は学習コストが高いかつ、上級者向けのため、チームレベル相談の上、取り入れてみてください。なぜ使うべきか。はこちらが参考になります。


🧠 TypeScript 開発憲章|AIレビュープロンプト(虎の巻)

最後にAIでコードレビューを行う際のプロンプトです。AIでコード改善してレビューすることで、よりドメインレビューが拾えると思います。以下、プロンプトです。


プロンプト本文

以下の原則に則って TypeScriptコードを厳しくレビューしてください。
原則違反があれば理由付きで指摘し、改善例を提示してください。

* 🥇 Step 1:状態・更新責務の破綻を検出せよ(最重要)

次の観点で 設計破綻を最優先で検出してください。
* 状態が boolean / null / optional(?) で表現されていないか
* Union型 によって ありえない状態が排除 されているか
* readonly が 業務的に不変な値のみに 付与されているか
* Props / 引数が 内部・子コンポーネントで変更されていないか
* 「読む型」と「更新する型」が 同一型に混在していないか
* 
* 👉 この原則でカバーすべきもの
* readonly
* boolean / optional / null
* 状態管理
* Props設計
* 
* ❌ 重大違反とみなす例:
* NGパターンのようなisLoading / isError フラグ乱立はUnionで制御すること
* useState<T | null>optional で状態や責務を押し込めている
* 全プロパティ readonly or なし

* 🔑 原則2:境界層から漏れていないか検証せよ

次に 安全境界の破れ を確認してください。
* 外部入力(API / Form / localStorage 等)を unknown で受けているか
* as T が 境界層以外で使用されていないか
* UI / Domain 層に unknown が漏れていないか
*
* 👉 この原則でカバー
* API
* fetch
* useState<T | null>
* 型ガード
* 
* ❌ 重大違反とみなす例:
* res.json() as T の直返し
* useState 初期化で null を前提にする設計
* Props に何でも入る型

* 🔑 原則3:型は「形」ではなく「制約と意図」を書け

* 最後に 型表現の質 を評価してください。
* Generics に 目的があり、extends 制約があるか
* keyof / typeof が 制約表現として使われているか
* optional = 「許可」、readonly = 「不変」として一貫しているか
* 型名・変数名から 責務・文脈が読み取れるか
*
* 👉 この原則でカバー
* Generics
* keyof
* typeof
* 命名
* 
* ❌ 改善対象の例:
* とりあえず <T>
* key: string
* interface の手動同期

🧾 出力フォーマット(必須)

レビュー結果は 必ず以下の形式 で出力してください。

  • 🚨 致命的(必ず修正)
  • ⚠️ 改善推奨
  • ✅ 良い設計

✍️ 修正後のコード例(必要な場合のみ)

// --- NGパターン ------------------
type State = {
  isLoading: boolean;
  isError: boolean;
  data?: User;
  error?: Error;
};
// Loading中かつSuccess状態の状態が可能

// 状態で null 可能
const [user, setUser] = useState<UserState | null>(null);

// --- OKパターン ------------------
type UserState =
  | { status: "loading" }
  | { status: "ready"; user: User }
  | { status: "error"; error: Error };  

const [state, setState] = useState<UserState>({ status: "loading" });
🪓 レビュールール
  • 忖度不要
  • マサカリ歓迎
  • 「なぜダメか」と「どう直すか」を必ずセットで示す
  • 推測ではなく 型から読み取れる事実のみ で指摘する
🎯 このプロンプトの狙い
  • boolean / null / optional 乱用を即座に検出
  • unknown 漏れを高精度で特定
  • 「型は意図を書くもの」という思想を一貫適用

最後に

はじめに書いた通り、TypeScriptは「型を書く技術」ではなく、**「設計を言語化する技術」**です。ドキュメントを作成するような気持ちでコードをかいていくと、より深い実装ができることでしょう。

引用

1
1
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?