Skip to main content
友田 陽大
Type safety & validation
TypeScript
アーキテクチャ設計
型安全性
B2B SaaS
フロントエンド

Production TypeScript Type-Safety Discipline: Banning any, Guarding Boundaries with Zod, and Forcing Exhaustiveness with NeverError

A practical guide to not letting type safety end as a 'policy.' Explained with real production code: strict-family tsconfig, banning any/as/enum, SSoT design parsing the boundary with Zod, forcing exhaustiveness with NeverError, satisfies, branded types, and type coverage in CI.

Published
Reading time
20 min read
Author
友田 陽大
Share

"We're TypeScript, so we're type-safe" — few words are as unreliable as this one sentence. Mix in any in even one spot, and all the code beyond it becomes "wish-based coding." Silence an error with as, and the compiler no longer protects you. Using TypeScript and being type-safe are completely different things.

This article is a practical guide for "rooting type safety in production not as a policy but as a discipline." On both a multi-person-team subscription learning platform (a Next.js 16 + Turborepo monorepo) and a Supabase + Expo monorepo, I lay down the same discipline of the principled banning of any/as/enum, exhaustiveness checking with NeverError, and type-coverage measurement in CI. I write that implementation-level decision axis faithfully to the TypeScript official and Zod official docs, but with a thicker decision axis than the official docs.

The basis of this article: TypeScript 5.x family (satisfies and const type parameters were introduced in 5.0, using/Disposable in 5.2), Zod 4 (stable). The APIs are based on the current spec confirmed at typescriptlang.org and zod.dev.

Note that "end-to-end type safety" spanning different languages and separate repositories (OpenAPI contract-first) is in the separate article Next.js 16 × Go × OpenAPI, and the concrete of making type safety effective in the payment domain is in the idempotency and type safety of subscription billing. This article concentrates on "the discipline of TypeScript itself within one repository," the foundation of those.

0. The big picture: the "6 disciplines" that underpin type safety

Type safety isn't a single feature but an accumulation of layered disciplines. Crumble a lower layer and the ones above become meaningless.

LayerDisciplinePurposeThis article's section
FoundationA strict tsconfigMake the compiler work at maximum output§1
LanguageCut off any/as/enumPlug the type's holes§2
BoundaryParse with ZodDon't trust external input§3
ModelA discriminated unionMake invalid states unrepresentable§4, §6
ExhaustivenessNeverErrorMake a missed branch a compile error§5
Identitybranded typesPrevent mixing up IDs and amounts§7

And only by enforcing these in CI does the discipline survive even at team scale (§8). Let me look at them in order.


1. Foundation: tsconfig is the compiler's "output setting"

The first thing to do is make the compiler work at full power. strict: true is a starting point, not an endpoint. Only by putting in the additional flags the tsconfig reference defines does it become a defense that works in the field.

// 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ファイル単位のトランスパイル安全性
  }
}

What strict: true enables are noImplicitAny, strictNullChecks, strictFunctionTypes, strictPropertyInitialization, useUnknownInCatchVariables, and so on. It's important that useUnknownInCatchVariables is included, by which the e of catch (e) becomes unknown rather than any (it pays off in §3).

1-1. Why put in noUncheckedIndexedAccess

This is the flag the overwhelming majority of teams don't put in, but that prevents the most accidents.

// 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();         // ガードを強制される(正しい)

Index access on arrays, Records, and objects can in reality always return undefined. It's just a flag that reflects that in the type, but it structurally prevents the accident of treating process.env.FOO (whose reality is string | undefined) as string.

1-2. The two meanings exactOptionalPropertyTypes distinguishes

"The key is absent" and "the key is present but the value is undefined" are different things in JavaScript. This flag distinguishes that in the type too.

interface Settings {
  theme?: "dark" | "light"; // 「省略可能」であって「undefined を代入してよい」ではない
}

const s: Settings = {};
s.theme = undefined; // フラグ ON ならエラー:undefined は許可されていない
// 「キーを消す」意図なら delete s.theme; が正しい

Subtle behavioral differences, like the key disappearing when you serialize { theme: undefined } to JSON, become a breeding ground for bugs. Fixing the meaning in the type is correct.

Decision axis: how strict to go. For a new repository, I put in the above full set from the start. Because the later you enable it, the more the fix cost grows exponentially. If retrofitting onto an existing large codebase, it's realistic to introduce only noUncheckedIndexedAccess in a separate branch in stages and crush it file by file.


2. Language: cut off the type's 3 big holes any / as / enum

Once you've hardened the foundation with tsconfig, next is plugging the holes open in the code. Cut off, in priority order, the 3 holes that destroy type safety.

2-1. Ban any, receive with unknown + narrowing

any means "disable type checking." Pass it once and it propagates, robbing the safety of everything it touches. Instead, use unknown (anything goes in, but you can do nothing without narrowing it).

anyunknownA concrete type
Receives assignment✅ Anything✅ Anything❌ Only matching types
Use as-is✅ (no check = dangerous)❌ Narrowing required✅ Safe
Propagation of type errors❌ Spreads to the surroundings✅ Confined locally✅ None
Where to useIn principle, noneThe receiving point for external inputNormally everything
// ❌ 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");
}

That said, hand-written narrowing is verbose. The practical answer is to narrow external input in one shot with Zod, handled in §3.

2-2. An as cast is "a lie to the compiler"

A type assertion as T does nothing at runtime. It just silences the type check. Attach as User to a fetch result, and if the server returns a different shape, you can't prevent it.

// ❌ 最悪パターン:検証なしのキャスト。実体が違えば即クラッシュ
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 is acceptable only in the very narrow case where a human knows more than the compiler (e.g. casting the return of the DOM's getElementById to a specific element type). Even so, note that it's a different role from as const (don't widen a literal).

// as const は「キャスト」ではなく「これ以上広げるな」の指示。これは推奨
const ROLES = ["admin", "member", "guest"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "member" | "guest"

2-3. Avoid enum, use an as const union / object

The TypeScript official enum handbook itself recommends an as const object over enum. The reasons are clear.

  • A numeric enum has opaque values: output just 2 in a log and you can't read its meaning.
  • A const enum is inlined: if values drift due to a dependency-version difference, it becomes a bug of mistaking an if branch. It's also incompatible with isolatedModules.
  • A syntax that doesn't exist in JavaScript: enum is TS-specific, and the generated runtime object is counterintuitive too.

The alternative the official docs show is this.

// ✅ 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 より柔軟)
enumas const union / object
Runtime valueTends to be opaque (numeric)Readable as the string as-is
Consistency with JS❌ TS-specific syntax✅ Just an object
isolatedModules⚠️ const enum is incompatible✅ No problem
Zod integrationHard to mesh✅ Matches z.enum (§3)
BundleGenerates extra helpersMinimal

Conclusion: there's almost no reason to write enum in new code.


3. Boundary: Parse, don't validate — place Zod at the trust boundary

This is the heart of the discipline. The principle is "Parse, don't validate." Not "confirm the data is correct (validate)" but "convert it to the correct type (parse)." A value that passed through parse enters a safe zone the type system guarantees.

Everything entering the system from outside is untrustworthy — API responses, form input, environment variables, localStorage, the return of JSON.parse. Place Zod (currently Zod 4 stable) at these boundaries.

3-1. The schema is the single source of truth (SSoT) — derive types with z.infer

The biggest benefit is DRY: rather than hand-writing the type, derive it from the schema with z.infer. Change the schema and the type auto-follows, and divergence is fundamentally impossible.

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 }

If the type differs between input (before conversion) and output (after conversion), you can separate them with z.input / z.output (the above z.coerce.date() is input string, output Date).

3-2. Treat failure as a "value" with safeParse

parse throws an exception on failure. At the boundary, failure is within expectations, so use safeParse, which handles it with a result object rather than an exception, and branch in a typed way.

// 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 });
}

It's essential security-wise too. OWASP input validation's principle is "reject everything at the boundary." A Zod schema becomes the spec sheet for input validation as-is.

3-3. Parse environment variables too

process.env.X is string | undefined. Use it directly and you get the typical accident of "in production one environment variable is missing and it silently breaks." Parse them all at once at startup and crash (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. Model: make "invalid states unrepresentable" with a discriminated union

The true value of type safety is in modeling more than validation. The slogan is "Make illegal states unrepresentable" — make an invalid combination uncreatable as a type in the first place.

A common one is "an all-optional god object."

// ❌ 不正な状態が表現できてしまう
interface Fetch {
  loading: boolean;
  data?: User;
  error?: string;
}
// loading:true なのに data がある、error と data が同時にある…全部作れてしまう

With a discriminated union, enumerate only the possible states.

// ✅ 取りうる状態だけが型として存在する
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 を持たない → 触ろうとすると型エラー
  }
}

With the common literal (the discriminant) status, TypeScript automatically narrows each branch's type. A contradictory state like "loading even though there's data" — the type won't let you write it. This pays off for ETC (Easy To Change) too — even as states grow, the compiler tells you the affected spots (next section).


5. Exhaustiveness: make a "missed branch" a compile error with NeverError

The true value of a discriminated union comes out when combined with exhaustiveness checking. The never type represents "an impossible value," and uses the property that handling all cases leaves the rest as never (the official exhaustiveness pattern).

NeverError is making this a reusable custom error.

// never-error.ts —— 「ここには到達しないはず」を型と実行時の両方で守る
export class NeverError extends Error {
  constructor(value: never) {
    super(`Unreachable: unexpected value ${JSON.stringify(value)}`);
    this.name = "NeverError";
  }
}

The usage is just to receive it in the default of a switch.

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);
  }
}

Add { status: "cancelled" } to the union here, and state no longer converges to never in the default, making NeverError(state) a compile error.

Argument of type '{ status: "cancelled" }' is not assignable to parameter of type 'never'.

The reason this works is the reconciliation of ETC (ease of change) and reliability. Add one state, and all the spots forgetting to handle that state turn red in the build. Before writing a test, the compiler points at "fix here too" everywhere. With enum + string comparison you don't get this guarantee (one of the practical benefits of avoiding enum in §2-3). In my subscription platform, I apply this NeverError pattern to all of the domain's state machines — billing state, the commission ledger, authentication state, and so on.

The difference from the function version default: return assertNever(x): making it an exception class leaves "which invalid value it crashed on" in the stack trace, making cause isolation in production fast. The function version protects exhaustiveness too, but I take the exception class for observability.


6. satisfies: type-check while "not losing narrowing"

In §2 I wrote "as is a lie." So, what do you do when "you want to confirm this value satisfies type T, but don't want to lower the precision of literals"? TypeScript 5.0's satisfies operator is the answer.

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 "imposes the constraint but doesn't throw away the inferred concrete type." It's a third way, different from both as (a runtime-powerless lie) and a type annotation (which widens). It's heavily used for config objects, mappings, and constant tables. Relatedly, 5.0's const type parameter (making <const T> give as const-equivalent inference to a function argument) is also a tool of the same "don't lower precision" family.


7. branded types: stop "mixing up" IDs and amounts with the type

A string userId and a string orderId aren't distinguished in TypeScript's structural typing. Get the argument order wrong and it passes. With a branded (nominal) type, make them treated as different things even with the same structure.

// 公称型を作る(実行時コストゼロ。型レベルのタグだけ)
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); // ❌ コンパイルエラー:引数の取り違えを型が検出

With Zod, you can attach a brand at the same time as the boundary parse (.brand()). What's powerful is being able to bake into the type the very fact of being "a validated value."

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");

Protect amounts with the same idea. Make cents (an integer, the minor unit) and yen (display) different brands, and the accident of carelessly passing an amount off by 100× is stopped by the type. How I thoroughly applied this in the payment domain is detailed in the idempotency and type safety of subscription billing.


8. CI: don't rely on "human goodwill" for the discipline

The disciplines so far will definitely rot unless enforced. One review oversight and any gets in, then propagates. The only way to make it survive at team scale is to have the machine protect it.

8-1. Forbid the holes at the syntax level with ESLint

Set typescript-eslint's type-information rules to 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" }
    ]
  }
}

The no-unsafe-* family is important — even if you don't write any directly, it detects the instant any slips in via JSON.parse or an untyped library. This makes "banning any" effective.

8-2. Gate on tsc and type coverage

As CI jobs, make type checking and type-coverage measurement merge requirements.

# 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 visualizes the proportion of any (spots collapsed into it) in the code. Drop below the threshold (e.g. 99%) and CI fails. It's a mechanism to stop "the types loosened before you knew it" with a number. In my monorepo, I run these as Turborepo tasks across all packages and enforce them per PR.


9. Common pitfalls

Patterns I see repeatedly in the field where you disable type safety yourself.

9-1. as to silence an error

// ❌ 「とりあえず通す」ための as。バグを未来に先送りしているだけ
const config = loadConfig() as AppConfig;

Erase the error with as and the reality hasn't changed. The compiler's warning is a sign that "the implementation is wrong." What you should erase is not the warning but the cause. At a boundary, replace it with §3's parse.

9-2. A hand-written type guard diverges from the type

// ❌ 型と独立した手書きガード。User にフィールドを足してもガードは更新されない
function isUser(x: unknown): x is User {
  return typeof x === "object" && x !== null && "id" in x; // email を見ていない!
}

The is User predicate is a human's claim, and the compiler doesn't verify whether the contents are correct. Add email to User and this guard silently passes, and the type and reality silently diverge. z.infer from a Zod schema and the schema = the guard = the type always match (§3). This is the reason for "don't write hand-written guards."

9-3. Trusting the return of JSON.parse as a type

// ❌ JSON.parse は any 相当。as で被せた型は何の保証もない
const data = JSON.parse(raw) as ApiResponse;

// ✅ unknown で受けて parse する
const data = ApiResponseSchema.parse(JSON.parse(raw));

The return of JSON.parse is effectively any. The compiler has no way of knowing the contents of a string that came from outside. Always interpose parse.

9-4. Assuming fetch().json() is typed

As touched on in §2-2, the return of res.json() is Promise<any>. as User is a lie. A value over the network is the most untrustworthy input. Receiving it with Zod's safeParse is the only correct answer.

9-5. Missing exhaustiveness with enum comparison

A chain of enum + if/else, even when you add a case, the compiler doesn't point out the gap. Replace it with §4's discriminated union + §5's NeverError and the gap fails in the build.


10. Why it works cross-cuttingly (why this discipline becomes "value")

Finally, let me organize, with design principles, why this discipline is not mere fastidiousness but business value.

  • Security: parse all external input at the boundary (§3) = the implementation of OWASP input validation. The type doubles as the spec.
  • Maintainability / ETC: types localize the affected range of a change. Add a state and NeverError points at all the gap spots (§4, §5). Refactoring becomes "mechanical work" rather than "a prayer."
  • Reliability: make invalid states unrepresentable (§4) = the seed of a bug can't exist as a type in the first place.
  • DRY: the schema is the single source of truth of type, validation, and documentation (§3). Divergence doesn't occur.
  • Testability: parse functions and pure functions alike have input/output fixed by types, making tests easy to write. The area you can crush with types doesn't need tests written (what should be tested is just the logic branches).

Type safety isn't only a story of "reducing bugs." It's the very basis on which an enterprise judges "we can entrust it with this design." You're freed from the attrition of pointing out "this is any" to each other every review, and humans can spend time on the essential discussions of the domain.


Summary: type safety is a "discipline," not a "feature"

The key points in six lines at the end.

  1. tsconfig at maximum output: beyond strict, up to noUncheckedIndexedAccess and exactOptionalPropertyTypes (§1).
  2. Cut off the 3 big holes: anyunknown, as → parse, enum → an as const union (§2).
  3. Parse at the boundary: place Zod at the trust boundary, derive types with z.infer (SSoT), treat failure as a value with safeParse (§3).
  4. Make invalid states unrepresentable: a discriminated union + turn exhaustiveness into a compile error with NeverError (§4, §5).
  5. Precision and nominality: prevent widening with satisfies, and stop mixing up IDs / amounts with branded types (§6, §7).
  6. Enforce in CI: make no-explicit-any, no-unsafe-*, tsc --noEmit, and type coverage merge conditions (§8).

"With one person × generative AI (Claude Code), building fast, cheap, and safe" — the backbone that ensures that "safe" is this type-safety discipline. The code and decision axes in this article are what I actually lay down on the subscription learning platform operated by a multi-person team. For type safety spanning languages, see Next.js × Go × OpenAPI, and for application to the payment domain, see the idempotency and type safety of subscription billing.

For consultation on type-safe design, resolving type debt in an existing codebase, or building quality gates in CI, feel free to reach out from contact.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading