# 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: 2026-06-24
- Author: 友田 陽大
- Tags: TypeScript, アーキテクチャ設計, 型安全性, B2B SaaS, フロントエンド
- URL: https://tomodahinata.com/en/blog/typescript-type-safety-discipline-zod-nevererror-no-any
- Category: Type safety & validation

## Key points

- Using TypeScript and being type-safe are different things; type safety is an accumulation of 6 layered disciplines
- Beyond strict, put in noUncheckedIndexedAccess and exactOptionalPropertyTypes for maximum output from tsconfig
- Cut off the type's 3 big holes any / as / enum, replacing any with unknown, as with parse, and enum with an as const union
- At the boundary, place Zod with Parse, don't validate, derive types with z.infer (SSoT), and treat failure as a value with safeParse
- Turn exhaustiveness into a compile error with a discriminated union + NeverError, and force no-explicit-any and type coverage in CI

---

"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](/case-studies/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](https://www.typescriptlang.org/tsconfig) and [zod.dev](https://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](/blog/nextjs-go-openapi-end-to-end-type-safety), and **the concrete of making type safety effective in the payment domain** is in [the idempotency and type safety of subscription billing](/blog/subscription-platform-billing-idempotency-type-safety). 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.

| Layer | Discipline | Purpose | This article's section |
| --- | --- | --- | --- |
| **Foundation** | A strict `tsconfig` | Make the compiler work at maximum output | §1 |
| **Language** | Cut off `any`/`as`/`enum` | Plug the type's holes | §2 |
| **Boundary** | Parse with Zod | Don't trust external input | §3 |
| **Model** | A discriminated union | Make invalid states unrepresentable | §4, §6 |
| **Exhaustiveness** | NeverError | Make a missed branch a compile error | §5 |
| **Identity** | branded types | Prevent 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](https://www.typescriptlang.org/tsconfig) defines does it become a defense that works in the field.

```jsonc
// 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.**

```ts
// 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, `Record`s, 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.

```ts
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).

| | `any` | `unknown` | A 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 use | **In principle, none** | The receiving point for external input | Normally everything |

```ts
// ❌ 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](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions) `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.

```ts
// ❌ 最悪パターン：検証なしのキャスト。実体が違えば即クラッシュ
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).

```ts
// 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](https://www.typescriptlang.org/docs/handbook/enums.html) 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.

```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` union / object |
| --- | --- | --- |
| Runtime value | Tends 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 integration | Hard to mesh | ✅ Matches `z.enum` (§3) |
| Bundle | Generates extra helpers | Minimal |

**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](https://zod.dev) (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.**

```ts
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.

```ts
// 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).

```ts
// 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."

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

With a [discriminated union](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions), enumerate **only the possible states.**

```ts
// ✅ 取りうる状態だけが型として存在する
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](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#exhaustiveness-checking)).

NeverError is making this a reusable **custom error.**

```ts
// 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`.

```ts
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.**

```text
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](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html) is the answer.

```ts
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.

```ts
// 公称型を作る（実行時コストゼロ。型レベルのタグだけ）
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()`](https://zod.dev/api)). What's powerful is being able to **bake into the type the very fact of being "a validated value."**

```ts
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](/blog/subscription-platform-billing-idempotency-type-safety).

---

## 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`](https://typescript-eslint.io)'s type-information rules to `error`.

```jsonc
// 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.**

```bash
# 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

```ts
// ❌ 「とりあえず通す」ための 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

```ts
// ❌ 型と独立した手書きガード。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

```ts
// ❌ 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**: `any` → `unknown`, `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](/case-studies/subscription-learning-platform) operated by a multi-person team. For type safety spanning languages, see [Next.js × Go × OpenAPI](/blog/nextjs-go-openapi-end-to-end-type-safety), and for application to the payment domain, see [the idempotency and type safety of subscription billing](/blog/subscription-platform-billing-idempotency-type-safety).

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](/contact).
