# TypeScript type-level programming in practice [2026 edition] — erase illegal states with types and support production quality

> A complete guide that explains TypeScript's type-level programming narrowed to the range that 'works in practice.' With real code it shows practical techniques to eliminate illegal states with types while keeping readability and compile speed — discriminated unions and exhaustiveness checking (NeverError), satisfies, branded types, template literal types, mapped types, conditional types, NoInfer, and type testing.

- Published: 2026-06-24
- Author: 友田 陽大
- Tags: TypeScript, フロントエンド, アーキテクチャ設計, Next.js, セキュリティ, React
- URL: https://tomodahinata.com/en/blog/typescript-type-level-programming-practical-guide
- Category: Type safety & validation
- Pillar guide: https://tomodahinata.com/en/blog/typescript-type-safety-discipline-zod-nevererror-no-any

## Key points

- The purpose of type-level programming is to make illegal states unrepresentable (Make illegal states unrepresentable).
- The most cost-effective is discriminated unions + assertNever (NeverError) to detect missing branches at compile time.
- satisfies, branded types, and template literal types are the practical 'three sacred treasures' — validate without widening the type and prevent ID mix-ups.
- Because types vanish at runtime, validate external input at the boundary with Zod, etc., and derive the type from that result.
- Indecipherable type puzzles slow compilation and break maintainability, so keep them in the range that's readable, fast, and safe (KISS, YAGNI).

---

Types are not "annotations added later" but "a spec designed first." Good types take over part of testing and become a safety net for refactoring.

---

## 1. Design philosophy: make illegal states unrepresentable

Many bugs are born from "states that shouldn't be possible" being constructible on the type level. For example, the following type allows the contradiction of having both data and an error while loading.

```ts
// ❌ 矛盾した状態を表現できてしまう
interface State {
  isLoading: boolean;
  data?: User;
  error?: Error;
}
// isLoading=true かつ data あり、のような不正状態が型上は合法
```

Make it a discriminated union and you can express **only the states that can occur** with types.

```ts
// ✅ 取り得る状態だけを列挙。矛盾は型レベルで作れない
type State =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: Error };
```

The type guarantees that `data` exists only when `status` is `"success"`. Inside `if (state.status === "success")`, `state.data` can be used safely, and elsewhere it doesn't even exist. This is the starting point for everything.

---

## 2. Exhaustiveness checking: crush missing branches with `assertNever` (NeverError)

The true value of a discriminated union is that **when you add a new state, the compiler tells you "the spots you forgot to handle."** The key is an exhaustiveness check using the `never` type.

```ts
// 網羅漏れを「コンパイルエラー」に変える番人
class NeverError extends Error {
  constructor(value: never) {
    super(`Unreachable: unexpected value ${JSON.stringify(value)}`);
  }
}

function render(state: State): string {
  switch (state.status) {
    case "idle":
      return "待機中";
    case "loading":
      return "読み込み中…";
    case "success":
      return `ようこそ、${state.data.name} さん`;
    case "error":
      return `エラー: ${state.error.message}`;
    default:
      // ここに来ると state は never 型のはず。
      // 新しい status を足して case を書き忘れると、ここで型エラーになる。
      throw new NeverError(state);
  }
}
```

The moment you add `{ status: "refreshing" }` to `State`, this `switch` becomes a compile error, and the omission is exposed **at build time.** Noticing not when you run it but the moment you write it — this is the practical benefit of type-level design.

> Why a union type rather than `enum`: `enum` generates an object at runtime, is hard to tree-shake, and numeric enums have holes in type safety. An `as const` string union is more lightweight and safe, and compatible with exhaustiveness checking, so in practice the discipline of avoiding `enum` and leaning to unions is effective.

---

## 3. `satisfies`: validate without widening the type

"I want to inspect whether it conforms to a type, but I don't want to lose the inferred concrete type (literals)" — what solves this contradiction is the `satisfies` operator.

```ts
type Theme = "light" | "dark";

// ❌ 型注釈だと値が Record<Theme,string> に広がり、キー補完が効かない
const colorsA: Record<Theme, string> = { light: "#fff", dark: "#000" };

// ✅ satisfies なら「Theme を網羅しているか」を検査しつつ、
//    実際の型は { light: string; dark: string } のまま保たれる
const colors = {
  light: "#fff",
  dark: "#000",
} satisfies Record<Theme, string>;

colors.light; // string（具体的なキーで補完が効く）
// colors.blue は存在しない → コンパイルエラー（網羅・余剰の両方を検査）
```

You use it heavily for configuration objects, routing tables, and the like — situations where "I want to keep the shape but also leverage the concrete values." Most dangerous casts via `as` can be safely replaced with `satisfies`.

---

## 4. Branded types: prevent ID mix-ups with types (correctness, security)

If both `UserId` and `OrderId` are just `string`, compilation passes even if you mistake the argument order. In areas where a mix-up directly causes an accident, like payments or authorization, this is fatal. Distinguish them with branded types (a pseudo-implementation of nominal typing).

```ts
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

// 生成は検証を通った1か所だけに集約する（境界での型付け）
function toUserId(raw: string): UserId {
  if (!/^usr_/.test(raw)) throw new Error("invalid user id");
  return raw as UserId; // ここだけは as を許す（検証済みの単一の入口）
}

function refund(order: OrderId, user: UserId) {/* ... */}

const u = toUserId("usr_123");
const o = "ord_999" as OrderId;
refund(u, o); // ✅
// refund(o, u); // ❌ 引数の取り違えがコンパイルエラーになる
```

Just by "not flowing a raw `string` deep into functions," you can shut out with types a whole group of bugs like ID mix-ups, currency confusion, and the intrusion of unvalidated input. The discipline is to limit `as` to such "single validated entrances."

---

## 5. Template literal types: make the "shape" of a string a type

You can constrain the structure of a string (route, event name, key) with types.

```ts
type Route = `/blog/${string}` | `/case-studies/${string}` | "/";
const go = (r: Route) => {/* ... */};
go("/blog/typescript"); // ✅
// go("/blgo/typo");     // ❌ 形が違うとエラー

// キー変換と組み合わせて API を導出
type EventName<T extends string> = `on${Capitalize<T>}`;
type ClickHandler = EventName<"click">; // "onClick"
```

Protect typo-prone strings with types and the safety at refactor time rises a notch.

---

## 6. Mapped types and conditional types: derive types from types (DRY)

Writing the same information by hand into multiple types is a DRY violation. Derive from an existing type.

```ts
// マップ型＋キー再マッピング：T からゲッター型を自動生成
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<{ name: string; age: number }>;
// { getName: () => string; getAge: () => number }

// 条件型＋infer：配列要素型や戻り値型を抽出
type ElementOf<T> = T extends readonly (infer U)[] ? U : never;
type Item = ElementOf<string[]>; // string
```

Change the "original type" in one place and all derived types follow. Duplication of type definitions is duplication of knowledge, and a hotbed of divergence.

---

## 7. Generics and `NoInfer`: control inference

Generics "parameterize types" to raise reusability (ETC). Furthermore, with TypeScript 5.4's `NoInfer`, you can **stop type inference from a specific argument.**

```ts
// options.default の型から T が推論されるのを防ぎ、第1引数を真実とする
function createState<T>(initial: T, options: { default: NoInfer<T> }) {/* ... */}

createState<"a" | "b">("a", { default: "b" }); // ✅
// createState("a", { default: "c" });          // c が T を汚染しない＝意図通りエラーにできる
```

This is a tool for the designer to control "which of multiple arguments is the basis for inference." It works when building library-like APIs.

---

## 8. Connecting to the runtime boundary: types are "inside," validation is "the entrance"

The most important principle. **Types vanish at runtime.** External input (API responses, forms, environment variables) can't be protected by type annotations alone. Validate at the boundary at runtime (Zod, etc.) and derive the type from that result.

```ts
import { z } from "zod";

const UserSchema = z.object({ id: z.string(), name: z.string() });
type User = z.infer<typeof UserSchema>; // スキーマから型を導出（単一の真実）

async function getUser(raw: unknown): Promise<User> {
  return UserSchema.parse(raw); // ここで初めて型が「保証」される
}
```

Type-level design (correctness inside) and runtime validation (safety at the boundary) are two wheels. For details, it's handled in the [React Hook Form × Zod practical guide](/blog/react-hook-form). Receiving with `any` and flowing it deep is the act of abandoning this boundary.

---

## 9. Test types, don't break them

Since types are also an asset, they need tests to prevent regression.

```ts
import { expectTypeOf, test } from "vitest";

test("State の success は data を持つ", () => {
  expectTypeOf<Extract<State, { status: "success" }>>().toHaveProperty("data");
});

// 「コンパイルエラーになるべき」ことのテスト
// @ts-expect-error budget は許可された値のみ
const bad: Theme = "blue";
```

`@ts-expect-error` fixes the intent that "this should be an error" (if it accidentally starts passing, conversely this line becomes an error and you notice).

---

## 10. Anti-patterns (overdoing it, dangerous)

- ❌ **Receiving with `any` and flowing it deep.** Type safety collapses in a chain. Receive with `unknown` and narrow at the boundary.
- ❌ **Casual `as` casts.** The act of telling the type system a lie. Replace with `satisfies` or a branded-type generation function.
- ❌ **Overusing `enum`.** Union + `as const` is more lightweight, safe, and suited to exhaustiveness checking.
- ❌ **Indecipherable nesting of recursive/conditional types.** Compilation slows and becomes unmaintainable (KISS, YAGNI violation). Keep types in the "readable" range.
- ❌ **Skipping exhaustiveness checking.** Swallowing with `default` means you can't notice an omission when adding a state. Place `assertNever`.
- ❌ **Confusing types with runtime validation.** Types don't protect the boundary. Always validate external input at runtime.

---

## 11. FAQ (frequently asked questions)

**Q. Type-level programming seems hard. Where do I start?**
A. First, "discriminated unions + `assertNever`" alone pays off plenty. Next add `satisfies`, branded types, in the order you need them. You don't need advanced conditional types from the start (YAGNI).

**Q. `interface` or `type` — which to use?**
A. For object shapes, basically either is fine. If you use unions, mapped types, conditional types, or template literal types, you need `type`. As long as it's consistent in the team, that's enough.

**Q. Why avoid `enum`?**
A. Because of runtime code generation, hindering tree-shaking, and the type hole of numeric enums. An `as const` string union is lightweight and safe, and meshes with exhaustiveness checking.

**Q. Don't fancy types slow down compilation?**
A. They do. Deep recursive types and huge conditional types increase type-instantiation cost. A type that harms readability and compile speed is a sign of "overdoing it."

**Q. What's the difference between `as const` and `satisfies`?**
A. `as const` fixes literals (makes them read-only), and `satisfies` inspects type conformance while preserving inference. The two can be combined, and `{...} as const satisfies T` enables "fixed and inspected."

---

## Conclusion: types are "the strongest first test"

The essence of type-level programming is not arcane tricks but "**a design that makes mistakes unwritable.**" Don't pray at runtime; prevent at compile time.

1. Design **types where illegal states can't be expressed** (discriminated unions).
2. Detect missing branches at build time with **`assertNever` (NeverError).**
3. With **`satisfies`, branded types, and template literal types**, don't widen, don't mix up, and constrain the shape.
4. Derive types with **mapped types and conditional types** and erase duplication (divergence of knowledge) (DRY).
5. **Validate at runtime at the boundary** and run types and validation as two wheels.
6. **Don't overdo it.** Only types that are readable, fast, and safe become assets (KISS, YAGNI).

Code with solid types fears refactoring less, reviews faster, and breaks less in production. This is an investment in both development speed and reliability.

**If you need type design for systems where "correctness" is absolutely required, like finance and payments, or making existing code type-safe, feel free to reach out.** The case study below introduces the process of building idempotent payments and commission calculation as a team, under a type discipline that forbids `as` / `any` / `enum` and guarantees exhaustiveness with `NeverError`.
