「うちはTypeScriptだから型安全です」——この一文ほど、当てにならない言葉はありません。any が一箇所でも混ざれば、その先のコードは全部「願望ベースのコーディング」になります。as でエラーを黙らせれば、コンパイラはもう守ってくれません。TypeScriptを使うことと、型安全であることは、まったく別物です。
この記事は「型安全を、方針ではなく規律(discipline)として本番に根付かせる」ための実務ガイドです。私は複数名チームの サブスク学習プラットフォーム(Next.js 16 + Turborepo モノレポ)と、Supabase + Expo のモノレポの双方で、any/as/enum の原則禁止・NeverError による網羅検査・CIでの型カバレッジ計測という同じ規律を敷いています。その実装レベルの判断軸を、TypeScript 公式・Zod 公式に忠実に、しかし公式より判断軸を厚くして書きます。
本記事の基準:TypeScript 5.x 系(
satisfies・const型パラメータは 5.0、using/Disposableは 5.2 で導入)、Zod 4(stable)。API は typescriptlang.org と zod.dev で確認した現行仕様に基づきます。
なお、異なる言語・別リポジトリにまたがる「エンドツーエンド型安全」(OpenAPI 契約優先)は別記事 Next.js 16 × Go × OpenAPI に、決済ドメインに型安全を効かせた具体は サブスク決済の冪等性と型安全 に書きました。本記事はそれらの土台となる「1リポジトリ内の TypeScript そのものの規律」に集中します。
0. 全体像:型安全を支える「6つの規律」
型安全は単一の機能ではなく、層をなす規律の積み重ねです。下の層が崩れると上は意味をなしません。
| 層 | 規律 | 目的 | 本記事の章 |
|---|---|---|---|
| 基盤 | 厳格な tsconfig | コンパイラを最大出力で働かせる | §1 |
| 言語 | any/as/enum を断つ | 型の抜け穴を塞ぐ | §2 |
| 境界 | Zod で parse する | 外部入力を信頼しない | §3 |
| モデル | 判別可能なユニオン | 不正な状態を表現不能にする | §4・§6 |
| 網羅 | NeverError | 分岐漏れをコンパイルエラーにする | §5 |
| 同一性 | branded 型 | IDや金額の取り違えを防ぐ | §7 |
そしてこれらを CI で強制して初めて、規律はチーム規模でも生き残ります(§8)。順に見ていきます。
1. 基盤:tsconfig はコンパイラの「出力設定」である
まず最初にやるべきは、コンパイラをフルパワーで動かすことです。strict: true は出発点であって終点ではありません。tsconfig リファレンスが定義する追加フラグまで入れて初めて、現場で効く防御になります。
// tsconfig.json — 本番推奨の最小強化セット
{
"compilerOptions": {
"strict": true, // 下記strict系を一括有効化
"noUncheckedIndexedAccess": true, // 配列/インデックスアクセスに undefined を付与
"exactOptionalPropertyTypes": true, // `?:` と `| undefined` を区別する
"noImplicitOverride": true, // override キーワードを必須化
"noImplicitReturns": true, // 全パスで return を強制
"noFallthroughCasesInSwitch": true, // switch のフォールスルーを禁止
"noUnusedLocals": true,
"noUnusedParameters": true,
"verbatimModuleSyntax": true, // 実行時に成立しない import/export を禁止
"isolatedModules": true // 1ファイル単位のトランスパイル安全性
}
}
strict: true が有効化するのは noImplicitAny・strictNullChecks・strictFunctionTypes・strictPropertyInitialization・useUnknownInCatchVariables などです。useUnknownInCatchVariables が含まれているのが重要で、これにより catch (e) の e は any ではなく unknown になります(§3で活きます)。
1-1. なぜ noUncheckedIndexedAccess を入れるか
これは入れていないチームが圧倒的に多いが、最も事故を防ぐフラグです。
// noUncheckedIndexedAccess: false(既定)—— 嘘の型
const users: string[] = [];
const first = users[0]; // 型は string(だが実体は undefined!)
first.toUpperCase(); // 実行時に TypeError、コンパイルは通る
// noUncheckedIndexedAccess: true —— 正直な型
const first2 = users[0]; // 型は string | undefined
first2.toUpperCase(); // コンパイルエラー:Object is possibly 'undefined'
first2?.toUpperCase(); // ガードを強制される(正しい)
配列・Record・オブジェクトのインデックスアクセスは、実体としては常に undefined を返しうる。それを型に反映させるだけのフラグですが、process.env.FOO(実体は string | undefined)を string として扱う事故を構造的に防ぎます。
1-2. exactOptionalPropertyTypes が分ける2つの意味
「キーが無い」と「キーはあるが値が undefined」は、JavaScript では別物です。このフラグはそれを型でも区別します。
interface Settings {
theme?: "dark" | "light"; // 「省略可能」であって「undefined を代入してよい」ではない
}
const s: Settings = {};
s.theme = undefined; // フラグ ON ならエラー:undefined は許可されていない
// 「キーを消す」意図なら delete s.theme; が正しい
{ theme: undefined } を JSON にシリアライズするとキーが消える、といった微妙な挙動差がバグの温床になります。意味を型で固定するのが正解です。
判断軸:どこまで厳しくするか。 私は新規リポジトリでは上記フルセットを最初から入れます。後から有効化するほど修正コストが指数的に増えるためです。既存の大規模コードに後付けするなら、
noUncheckedIndexedAccessだけ別ブランチで段階導入し、ファイル単位で潰すのが現実的です。
2. 言語:型の3大抜け穴 any / as / enum を断つ
tsconfig で土台を固めたら、次はコードに開いた穴を塞ぐ番です。型安全を破壊する3つの抜け穴を、優先度順に断ちます。
2-1. any 禁止、unknown + ナローイングで受ける
any は「型チェックを無効化する」という意味です。一度通ると伝播し、触れたものすべての安全性を奪います。代わりに unknown(何でも入るが、絞らないと何もできない)を使います。
any | unknown | 具体型 | |
|---|---|---|---|
| 代入を受ける | ✅ 何でも | ✅ 何でも | ❌ 型が合うものだけ |
| そのまま使う | ✅(チェックなし=危険) | ❌ ナローイング必須 | ✅ 安全 |
| 型エラーの伝播 | ❌ 周囲に伝染する | ✅ 局所に閉じる | ✅ なし |
| 使いどころ | 原則なし | 外部入力の受け口 | 通常すべて |
// ❌ any:その先は無検査。プロパティ名のタイポも素通り
function handle(payload: any) {
return payload.usrId; // typo でも通る → 実行時に undefined
}
// ✅ unknown + ナローイング:使う前に必ず絞る
function handle(payload: unknown) {
if (
typeof payload === "object" &&
payload !== null &&
"userId" in payload &&
typeof payload.userId === "string"
) {
return payload.userId; // ここで初めて string として安全に使える
}
throw new Error("invalid payload");
}
とはいえ手書きのナローイングは冗長です。外部入力は Zod で一発で絞るのが実務解で、§3で扱います。
2-2. as キャストは「コンパイラへの嘘」
型アサーション as T は、実行時には何もしません。型チェックを黙らせるだけです。fetch の結果に as User を付けても、サーバーが違う形を返せば防げません。
// ❌ 最悪パターン:検証なしのキャスト。実体が違えば即クラッシュ
const res = await fetch("/api/user");
const user = (await res.json()) as User; // JSON.parse は any 相当。as で嘘の型を被せている
// ✅ parse する(§3)。実体と型が一致することを実行時に保証
const user = UserSchema.parse(await res.json());
as が許容できるのは、人間がコンパイラより多くを知っているごく狭い場合だけ(例:DOM の getElementById の戻りを特定要素型へ)です。それでも as const(リテラルを widening させない)とは役割が別物である点に注意してください。
// as const は「キャスト」ではなく「これ以上広げるな」の指示。これは推奨
const ROLES = ["admin", "member", "guest"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "member" | "guest"
2-3. enum を避け、as const ユニオン/オブジェクトを使う
TypeScript 公式の enum ハンドブック自身が、enum より**as const オブジェクト**を推奨しています。理由は明確です。
- 数値 enum は値が不透明:ログに
2とだけ出ても意味が読めない。 const enumはインライン展開される:依存のバージョン差で値がズレると、ifの分岐を取り違えるバグになる。isolatedModulesとも非互換。- JavaScript に存在しない構文:
enumは TS 独自で、生成される実行時オブジェクトも直感に反する。
公式が示す代替が、これです。
// ✅ as const オブジェクト + 派生ユニオン型(公式推奨)
const OrderStatus = {
Pending: "pending",
Paid: "paid",
Shipped: "shipped",
Cancelled: "cancelled",
} as const;
type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];
// "pending" | "paid" | "shipped" | "cancelled"
function advance(status: OrderStatus) { /* ... */ }
advance(OrderStatus.Paid); // 値で参照しても良い
advance("paid"); // リテラルでも通る(enum より柔軟)
enum | as const ユニオン/オブジェクト | |
|---|---|---|
| 実行時の値 | 不透明(数値)になりがち | そのまま文字列で読める |
| JS との整合 | ❌ TS独自構文 | ✅ ただのオブジェクト |
isolatedModules | ⚠️ const enum は非互換 | ✅ 問題なし |
| Zod 連携 | 噛み合わせにくい | ✅ z.enum と一致(§3) |
| バンドル | 余分なヘルパ生成 | 最小 |
結論:新規コードで enum を書く理由はほぼありません。
3. 境界:Parse, don't validate —— Zod を信頼境界に置く
ここが規律の心臓部です。原則は "Parse, don't validate"。「データが正しいか確認する(validate)」のではなく、「正しい型へ変換する(parse)」。parse を通った後の値は、型システムが保証する安全な領域に入ります。
外部からシステムに入るものはすべて信頼できない——API レスポンス、フォーム入力、環境変数、localStorage、JSON.parse の戻り。これらの境界に Zod(現行は Zod 4 stable)を置きます。
3-1. スキーマが唯一の真実源(SSoT)—— z.infer で型を導出する
最大の利点は DRY:型を手書きせず、スキーマから z.infer で導出します。スキーマを変えれば型も自動で追従し、乖離が原理的に起きません。
import { z } from "zod";
// スキーマ(実行時の検証ロジック)を一度だけ定義
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(["admin", "member", "guest"]), // §2-3 のユニオンと一致
createdAt: z.coerce.date(),
});
// 型はスキーマから導出する(手書きしない=SSoT)
type User = z.infer<typeof UserSchema>;
// { id: string; email: string; role: "admin"|"member"|"guest"; createdAt: Date }
入力(変換前)と出力(変換後)で型が違う場合は z.input / z.output で取り分けられます(上の z.coerce.date() は入力 string、出力 Date)。
3-2. safeParse で失敗を「値」として扱う
parse は失敗時に例外を投げます。境界では失敗が想定内なので、例外ではなく結果オブジェクトで扱う safeParse を使い、型付きで分岐します。
// API ルートでのリクエスト検証(信頼境界)
export async function POST(req: Request) {
const json: unknown = await req.json(); // JSON.parse 相当は unknown 扱いが正しい
const result = UserSchema.safeParse(json);
if (!result.success) {
// result.error は ZodError。型付きで安全にハンドリングできる
return Response.json({ errors: result.error.flatten() }, { status: 400 });
}
// result.data は User 型として保証されている。ここから先は安全地帯
const user = result.data;
return Response.json({ id: user.id });
}
セキュリティ的にも本質的です。OWASP の入力検証は「境界で全部弾く」が原則。Zod スキーマがそのまま入力検証の仕様書になります。
3-3. 環境変数も parse する
process.env.X は string | undefined。直接使うと「本番で環境変数が一個抜けていて静かに壊れる」典型事故になります。起動時に一括 parseして落とします(fail fast)。
// env.ts —— アプリ起動時に一度だけ検証する
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
NODE_ENV: z.enum(["development", "production", "test"]),
});
// 不正なら起動時に例外で即死させる(実行時の謎挙動より100倍デバッグしやすい)
export const env = EnvSchema.parse(process.env);
// 以降 env.DATABASE_URL は string として保証される
4. モデル:判別可能なユニオンで「不正な状態を表現不能」にする
型安全の真価は、検証以上にモデリングにあります。標語は "Make illegal states unrepresentable"——不正な組み合わせを、そもそも型として作れなくする。
ありがちなのは「全部 optional の神オブジェクト」です。
// ❌ 不正な状態が表現できてしまう
interface Fetch {
loading: boolean;
data?: User;
error?: string;
}
// loading:true なのに data がある、error と data が同時にある…全部作れてしまう
判別可能なユニオン(discriminated union)で、ありえる状態だけを列挙します。
// ✅ 取りうる状態だけが型として存在する
type FetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User } // success の時だけ data がある
| { status: "error"; message: string }; // error の時だけ message がある
function render(state: FetchState) {
switch (state.status) {
case "success":
return state.data.email; // data は success 分岐でのみアクセス可
case "error":
return state.message; // message も同様
// idle / loading は data を持たない → 触ろうとすると型エラー
}
}
status という共通リテラル(判別子)で、TypeScript が各分岐の型を自動で絞り込みます。「data があるのに loading」のような矛盾状態は、書こうとしても型が許しません。 これが ETC(Easy To Change)にも効きます——状態が増えても、影響箇所はコンパイラが教えてくれる(次章)。
5. 網羅:NeverError で「分岐漏れ」をコンパイルエラーにする
判別可能なユニオンの真価は、網羅性チェックと組み合わせた時に出ます。never 型は「ありえない値」を表し、全ケースを処理し切ると残りが never になるという性質を利用します(公式の exhaustiveness パターン)。
これを再利用可能なカスタムエラーにしたのが NeverError です。
// never-error.ts —— 「ここには到達しないはず」を型と実行時の両方で守る
export class NeverError extends Error {
constructor(value: never) {
super(`Unreachable: unexpected value ${JSON.stringify(value)}`);
this.name = "NeverError";
}
}
使い方は、switch の default で受けるだけです。
function label(state: FetchState): string {
switch (state.status) {
case "idle": return "待機中";
case "loading": return "読み込み中";
case "success": return "完了";
case "error": return "エラー";
default:
// 全ケースを処理していれば state は never 型 → コンパイル通過
throw new NeverError(state);
}
}
ここに { status: "cancelled" } をユニオンへ追加すると、default で state が never に収束しなくなり、NeverError(state) がコンパイルエラーになります。
Argument of type '{ status: "cancelled" }' is not assignable to parameter of type 'never'.
これが効く理由は ETC(変更容易性)と信頼性の両立です。状態を1つ足すと、その状態を扱い忘れている全箇所がビルドで赤くなる。テストを書く前に、コンパイラが「ここも直せ」と全部指してくれる。enum + 文字列比較ではこの保証は得られません(§2-3で enum を避ける実利のひとつ)。私のサブスク基盤では、課金状態・コミッション台帳・認証状態など、ドメインの状態機械すべてにこの NeverError パターンを適用しています。
default: return assertNever(x)関数版との違い:例外クラスにしておくと、スタックトレースに「どの不正値で落ちたか」が残り、本番での原因切り分けが速い。関数版でも網羅性は守れますが、私は観測性のため例外クラスを採ります。
6. satisfies:型チェックしつつ「絞り込みを失わない」
§2 で「as は嘘」と書きました。では「この値が型 T を満たすことは確認したいが、リテラルの精度は落としたくない」時はどうするか。TypeScript 5.0 の satisfies 演算子がその答えです。
type RouteConfig = Record<string, { path: string; auth: boolean }>;
// ❌ 注釈(: RouteConfig)—— 型は RouteConfig に「広がる」
const routesA: RouteConfig = {
home: { path: "/", auth: false },
dashboard: { path: "/app", auth: true },
};
routesA.home; // OK だが…
routesA.unknown; // ⚠️ Record なのでタイポも string キーとして通ってしまう
// ✅ satisfies —— RouteConfig に適合することを検証しつつ、具体的なキーは保持
const routesB = {
home: { path: "/", auth: false },
dashboard: { path: "/app", auth: true },
} satisfies RouteConfig;
routesB.home; // OK
routesB.unknown; // ✅ コンパイルエラー:存在しないキー
satisfies は「制約は課すが、推論された具体型は捨てない」。as(実行時に無力な嘘)とも、型注釈(widening する)とも違う、第三の道です。設定オブジェクト・マッピング・定数テーブルで多用します。関連して 5.0 の const 型パラメータ(<const T> で as const 相当の推論を関数引数に効かせる)も、同じ「精度を落とさない」系の道具です。
7. branded 型:IDと金額の「取り違え」を型で止める
string の userId と string の orderId は、TypeScript の構造的型付けでは区別されません。引数の順序を間違えても通ってしまう。branded(nominal)型で、構造が同じでも別物として扱わせます。
// 公称型を作る(実行時コストゼロ。型レベルのタグだけ)
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
declare function cancelOrder(user: UserId, order: OrderId): void;
const u = "u_123" as UserId;
const o = "o_456" as OrderId;
cancelOrder(u, o); // ✅
cancelOrder(o, u); // ❌ コンパイルエラー:引数の取り違えを型が検出
Zod なら境界の parse と同時に brand を付けられます(.brand())。「検証済みの値」であること自体を型に焼き込めるのが強力です。
const UserId = z.string().uuid().brand<"UserId">();
type UserId = z.infer<typeof UserId>; // string & z.$brand<"UserId">
// parse を通った値だけが UserId 型を名乗れる=「未検証の文字列」と区別される
const id = UserId.parse("550e8400-e29b-41d4-a716-446655440000");
金額も同じ発想で守ります。cents(整数・最小単位)と yen(表示)を別の brand にすれば、100倍ズレた金額をうっかり渡す事故が型で止まります。決済ドメインでこれをどう徹底したかは サブスク決済の冪等性と型安全 に詳述しました。
8. CI:規律を「人間の善意」に頼らない
ここまでの規律は、強制しなければ必ず腐ります。レビューの見落とし一回で any が入り、次第に伝播する。チーム規模で生き残らせる唯一の方法は、機械に守らせることです。
8-1. ESLint で抜け穴を構文レベルで禁止
typescript-eslint の型情報つきルールを error に設定します。
// eslint 設定(抜粋)
{
"rules": {
"@typescript-eslint/no-explicit-any": "error", // any を書けなくする
"@typescript-eslint/no-unsafe-assignment": "error", // any 由来の代入を禁止
"@typescript-eslint/no-unsafe-member-access": "error", // any のプロパティ参照を禁止
"@typescript-eslint/no-unsafe-call": "error",
"@typescript-eslint/no-unsafe-return": "error",
"@typescript-eslint/consistent-type-assertions": [ // as キャストを抑制
"error", { "assertionStyle": "never" }
]
}
}
no-unsafe-* 系が重要で、any を直接書かなくても、JSON.parse や型なしライブラリ経由で any が紛れ込んだ瞬間に検出します。これが「any 禁止」を実効化します。
8-2. tsc と型カバレッジをゲートにする
CI のジョブとして、型チェックと型カバレッジ計測をマージの必須条件にします。
# 1. 型エラーゼロを保証(emit せず型検査だけ)
tsc --noEmit
# 2. lint(上の no-explicit-any 等)
eslint . --max-warnings 0
# 3. 型カバレッジ:明示的な型がついている割合を計測し、しきい値で落とす
type-coverage --strict --at-least 99 --ignore-files "**/*.test.ts"
type-coverage --strict は、コード中で any(に潰れている箇所)の割合を可視化します。しきい値(例:99%)を下回ったら CI が落ちる。「いつの間にか型が緩んでいた」を数値で止める仕組みです。私のモノレポでは、これらを Turborepo のタスクとして全パッケージ横断で回し、PRごとに強制しています。
9. よくある落とし穴
現場で繰り返し見る、型安全を自分で無効化してしまうパターンです。
9-1. エラーを黙らせるための as
// ❌ 「とりあえず通す」ための as。バグを未来に先送りしているだけ
const config = loadConfig() as AppConfig;
as でエラーが消えても、実体は変わっていません。コンパイラの警告は「実装が間違っている」サインです。消すべきは警告ではなく原因。境界なら §3 の parse に置き換えます。
9-2. 手書きの型ガードが型と乖離する
// ❌ 型と独立した手書きガード。User にフィールドを足してもガードは更新されない
function isUser(x: unknown): x is User {
return typeof x === "object" && x !== null && "id" in x; // email を見ていない!
}
is User という述語は人間の主張であって、コンパイラは中身が正しいか検証しません。User に email を追加してもこのガードは黙って通り、型と実体が静かに乖離します。Zod スキーマから z.infer すれば、スキーマ=ガード=型が常に一致します(§3)。これが「手書きガードを書かない」理由です。
9-3. JSON.parse の戻りを型として信じる
// ❌ JSON.parse は any 相当。as で被せた型は何の保証もない
const data = JSON.parse(raw) as ApiResponse;
// ✅ unknown で受けて parse する
const data = ApiResponseSchema.parse(JSON.parse(raw));
JSON.parse の戻りは実質 any。外から来た文字列の中身を、コンパイラは知りようがありません。必ず parse を挟む。
9-4. fetch().json() を型付きと思い込む
§2-2 で触れた通り、res.json() の戻りは Promise<any>。as User は嘘。ネットワーク越しの値こそ最も信頼できない入力です。Zod の safeParse で受けるのが唯一の正解です。
9-5. enum 比較で網羅性を取りこぼす
enum + if/else の連鎖は、ケースを足してもコンパイラが漏れを指摘しません。§4 の判別可能ユニオン + §5 の NeverError に置き換えれば、漏れがビルドで落ちます。
10. 横断的に効く理由(なぜこの規律が「価値」になるのか)
最後に、この規律が単なる潔癖ではなく事業価値である理由を、設計原則で整理します。
- セキュリティ:外部入力を境界で全部 parse する(§3)= OWASP 入力検証の実装。型が仕様書を兼ねる。
- 保守性 / ETC:型が変更の影響範囲を局所化する。状態を足すと NeverError が全漏れ箇所を指す(§4・§5)。リファクタが「祈り」でなく「機械的作業」になる。
- 信頼性:不正な状態を表現不能にする(§4)= そもそもバグの種が型として存在できない。
- DRY:スキーマが型・検証・ドキュメントの唯一の真実源(§3)。乖離が起きない。
- テスト容易性:parse 関数も純粋関数も入出力が型で固定され、テストが書きやすい。型で潰せる領域はテストを書かなくて済む(テストすべきはロジックの分岐だけ)。
型安全は「バグを減らす」だけの話ではありません。「この設計なら任せられる」とエンタープライズが判断する根拠そのものです。レビューで毎回「ここ any ですよ」と指摘し合う消耗から解放され、人間はドメインの本質的な議論に時間を使えるようになります。
まとめ:型安全は「規律」であって「機能」ではない
要点を最後に6行で。
- tsconfig を最大出力に:
strictに加えnoUncheckedIndexedAccess・exactOptionalPropertyTypesまで(§1)。 - 3大抜け穴を断つ:
any→unknown、as→ parse、enum→as constユニオン(§2)。 - 境界で parse する:Zod を信頼境界に置き、
z.inferで型を導出(SSoT)、safeParseで失敗を値として扱う(§3)。 - 不正な状態を表現不能に:判別可能ユニオン + NeverError で網羅性をコンパイルエラー化(§4・§5)。
- 精度と公称性:
satisfiesで widening を防ぎ、branded 型で ID・金額の取り違えを止める(§6・§7)。 - CI で強制:
no-explicit-any・no-unsafe-*・tsc --noEmit・型カバレッジをマージ条件に(§8)。
「一人 × 生成AI(Claude Code)で、速く・安く・安全に」作る——その「安全」を担保する背骨が、この型安全規律です。本記事のコードと判断軸は、複数名チームで運用する サブスク学習プラットフォーム で実際に敷いているものです。言語を跨ぐ型安全は Next.js × Go × OpenAPI を、決済ドメインへの適用は サブスク決済の冪等性と型安全 を併せてどうぞ。
型安全な設計・既存コードベースの型負債の解消・CIでの品質ゲート構築のご相談は、お問い合わせからお気軽にどうぞ。