---
name: forge-typescript
description: TypeScript discipline. No `any`, narrow types via `unknown`, exhaustive unions with `assertNever`, branded primitives, Result types, schema-validated boundaries with zod. Contains ready-to-paste implementations of every pattern. Use when writing or reviewing TypeScript code that must survive contact with reality.
license: MIT
---

# forge-typescript

You are writing TypeScript that will run in production and be edited by other people in two years. Default agent-written TypeScript reaches for `any` whenever the type gets hard, builds discriminated unions without exhaustive checks, and uses `string` where a branded type would prevent half the bugs. This skill exists to fix all of that.

The mental model: TypeScript is not a more-verbose JavaScript. It is a proof system. Every cast, every `any`, every non-null assertion is a hole in the proof.

## Quick reference (the things you must never write)

1. `: any` or `as any`
2. `// @ts-ignore` or `// @ts-nocheck` (without a linked issue)
3. `x!.something` (non-null assertion) outside tests
4. `JSON.parse(input)` without an immediate schema validation
5. `: Function` or `: Object` as a type
6. `as` casts without a comment explaining why the compiler is wrong
7. `User | null` where you mean "we did not find one" - use Result instead
8. `try { ... } catch (e) { console.log(e) }` swallowing errors silently
9. `Promise<void>` returns that are never awaited (use `void promise()` to mark intentional fire-and-forget)
10. Hand-rolled email/URL/UUID validation regex

## Hard rules

### The `any` cookbook

**1. `any` is banned. Always.** Use `unknown` and narrow at the boundary.

```ts
// BAD
function parseConfig(input: any): Config { return input; }

// GOOD: validate at the boundary, types beyond it are guarantees
const ConfigSchema = z.object({ host: z.string(), port: z.number().int().positive() });
type Config = z.infer<typeof ConfigSchema>;

function parseConfig(input: unknown): Config {
  return ConfigSchema.parse(input);
}
```

**What to use instead of `any`:**

| Situation | Use this |
| --- | --- |
| External JSON / API response / message payload | `unknown` + schema validation (zod, valibot) |
| Truly generic helper | Generic parameter: `<T>(x: T) => T` |
| Function that takes anything callable | `(...args: unknown[]) => unknown`, or be specific |
| "I will type this later" | `unknown`, the compiler will force you to narrow |
| Library type missing | Declare a `.d.ts` shim. Specific is better than `any`. |
| Dynamic property access | Index signature with a typed value: `Record<string, T>` |
| Recursive type that's hard | Mutual recursion + helper types, not `any` |

**2. `as` casts are a last resort.** Each cast is a claim you cannot prove.

```ts
// BAD: cast without justification
const user = JSON.parse(raw) as User;

// GOOD: validate (preferred)
const user = UserSchema.parse(JSON.parse(raw));

// ACCEPTABLE: cast with a comment, when validation is genuinely impossible
// The compiler infers `string | undefined` here because of `noUncheckedIndexedAccess`,
// but the upstream invariant guarantees this slot is filled. See ARCH-23.
const first = items[0] as string;
```

**3. `!` (non-null assertion) is banned outside narrowed scopes.**

```ts
// BAD
const user = users.find(u => u.id === id)!;
console.log(user!.email);

// GOOD: guard
const user = users.find(u => u.id === id);
if (!user) throw new UserNotFoundError(id);
console.log(user.email);
```

**4. `// @ts-ignore` and `// @ts-nocheck` are forbidden.** If you must escape the type system, write a comment naming the issue:

```ts
// @ts-expect-error - upstream lib types are wrong for this version; see issue #1234
import { thing } from "broken-lib";
```

`@ts-expect-error` is preferred over `@ts-ignore` because it fails when the underlying issue is fixed - guaranteeing the workaround gets removed.

### Exhaustive unions

**5. `switch` on a discriminant is exhaustive.** Use `assertNever`:

```ts
// reference helper
export function assertNever(x: never): never {
  throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
}

// use it
type Event =
  | { type: "created"; user: User }
  | { type: "updated"; user: User; changes: Partial<User> }
  | { type: "deleted"; id: string };

function handle(event: Event): string {
  switch (event.type) {
    case "created": return `welcome ${event.user.name}`;
    case "updated": return `${event.user.name} updated ${Object.keys(event.changes).length} fields`;
    case "deleted": return `goodbye ${event.id}`;
    default: return assertNever(event);   // compile error if you add a variant and forget a case
  }
}
```

### Branded types

**6. Branded types for identifiers.** Now a `UserId` cannot be passed where an `OrderId` is expected.

```ts
// reference: a zero-runtime brand pattern
type Brand<T, B> = T & { readonly __brand: B };

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

// constructors that validate where appropriate
function UserId(s: string): UserId { return s as UserId; }
function OrderId(s: string): OrderId { return s as OrderId; }

function Email(s: string): Email {
  if (!s.includes("@") || s.length > 320) throw new InvalidEmailError(s);
  return s as Email;
}

// usage: type system now prevents this bug
declare function refund(orderId: OrderId): void;
const uid = UserId("u_01HXY...");
refund(uid);  // ✗ compile error: UserId is not assignable to OrderId
```

**7. No primitives as parameters for domain values.**

```ts
// BAD: hostile to callers
function transfer(amount: number, fromAccount: string, toAccount: string) {}

// GOOD: mistakes are hard
function transfer(amount: Money, from: AccountId, to: AccountId) {}

// Money is itself a struct, never a float
type Money = { amount_cents: number; currency: "USD" | "EUR" | "KZT" };
```

### Result types

**8. Use Result for expected failures. Use exceptions for unexpected ones.**

```ts
// reference Result type
export type Ok<T>  = { ok: true;  value: T };
export type Err<E> = { ok: false; error: E };
export type Result<T, E> = Ok<T> | Err<E>;

export const ok  = <T>(value: T): Ok<T>   => ({ ok: true, value });
export const err = <E>(error: E): Err<E>  => ({ ok: false, error });

// use it for expected failures
async function fetchUser(id: UserId): Promise<Result<User, "not_found" | "forbidden">> {
  const row = await db.users.findUnique({ where: { id } });
  if (!row) return err("not_found");
  if (!canAccess(currentUser, row)) return err("forbidden");
  return ok(row);
}

// caller handles the failure shape, no try/catch
const result = await fetchUser(id);
if (!result.ok) {
  switch (result.error) {
    case "not_found": return errorResponse("user_not_found", 404);
    case "forbidden": return errorResponse("forbidden", 403);
  }
}
const user = result.value;  // narrowed to User
```

**Use exceptions for:** network down, disk full, programmer error, anything you cannot recover from at this layer.

### Strict tsconfig

**9. `tsconfig.json` enables `"strict": true`. Plus:**

```jsonc
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,     // array indexing returns T | undefined (correct)
    "exactOptionalPropertyTypes": true,   // { foo?: string } ≠ { foo: string | undefined }
    "noImplicitOverride": true,           // must write `override`
    "noFallthroughCasesInSwitch": true,
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,           // leading underscore to opt out: _unused
    "verbatimModuleSyntax": true,         // import type when you mean it
    "isolatedModules": true,              // single-file transpilation (vite, swc) won't break
    "target": "ES2023",
    "moduleResolution": "bundler"
  }
}
```

### Schema validation at boundaries

**10. External input arrives as `unknown`.** API responses, JSON.parse, message payloads, env vars - all `unknown` until validated.

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

// env validation at startup
const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  STRIPE_API_KEY: z.string().startsWith("sk_"),
  SESSION_SECRET: z.string().min(32),
  LOG_LEVEL: z.enum(["trace", "debug", "info", "warn", "error"]).default("info"),
});
type Env = z.infer<typeof EnvSchema>;

export const env: Env = (() => {
  const parsed = EnvSchema.safeParse(process.env);
  if (!parsed.success) {
    console.error("invalid environment:", parsed.error.format());
    process.exit(1);
  }
  return parsed.data;
})();
```

**11. Type guards have explicit predicates.**

```ts
function isOrder(x: unknown): x is Order {
  return typeof x === "object" && x !== null && "id" in x && "total_cents" in x;
}
```

Inline guards are fine for one-off uses. Named ones for reuse. For anything crossing a network boundary, use a schema library, not a hand-rolled guard.

### Async discipline

**12. Always `await` or explicitly `void` a promise.** Floating promises swallow errors.

```ts
// BAD: error silently disappears
sendEmail(user);

// GOOD: intentional fire-and-forget, visible to readers and linters
void sendEmail(user);

// BEST: handle the result
try {
  await sendEmail(user);
} catch (err) {
  logger.error({ err, userId: user.id }, "send email failed");
}
```

**13. No `async` keyword without an `await` inside.** If a function does not actually wait, it should not be async.

### Annotations vs inference

**14. Let the compiler infer when types are obvious.**

```ts
// noise
const x: number = 5;

// clean
const x = 5;
```

**15. Annotate function returns.** Even when inference works - return-type annotations document intent and catch a class of bugs where a refactor silently changes the return type.

```ts
async function loadUser(id: UserId): Promise<User> {   // explicit return type
  return db.users.findUniqueOrThrow({ where: { id } });
}
```

**16. `const` assertions for literal types.**

```ts
const STATUSES = ["active", "archived", "banned"] as const;
type Status = typeof STATUSES[number];  // "active" | "archived" | "banned"
```

### Imports and modules

**17. Import types with `import type`.** Cleanly separates runtime from type-only imports; bundler can drop type imports.

```ts
import type { User, Order } from "./types";
import { db } from "./db";
```

**18. No `* as Module` imports when you only use one symbol.** Explicit named imports tree-shake better.

## Common AI-output patterns to reject

| Pattern | Why it is wrong | Fix |
| --- | --- | --- |
| `function f(x: any)` | Opts out of types | `unknown` + narrow / schema |
| `as any` | Same as above, with attitude | `unknown` + narrow / schema |
| `// @ts-ignore` | Silences without intent | `// @ts-expect-error - reason, issue #` |
| `users.find(...)!` | Promises non-null without proof | Guard or `findOrThrow` |
| `JSON.parse(input)` then field access | Untyped data treated as typed | `Schema.parse(JSON.parse(input))` |
| `: Function`, `: Object` | Erases shape | Specific signature or `Record<string, T>` |
| `data: any[]` | Array of nothing | `unknown[]` or `T[]` |
| `Record<string, any>` | "JSON" without structure | `z.record(z.unknown())` validated, then `Record<string, T>` |
| `Promise<void>` returned and forgotten | Hidden async; errors lost | `void promise()` or `await` |
| `if (x === undefined || x === null)` | Manual nullish | `if (x == null)` (only safe nullish check) |
| Custom email regex | All wrong, always | `z.string().email()` |
| `try/catch` swallowing | Silent failures | Log + rethrow or convert to Result |
| Class with all-static methods | Namespace masquerade | Module with named exports |
| `enum` (TS enum, not const enum) | Runtime cost, type-erased poorly | `as const` literal union |

## Worked example: a typed handler end to end

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

// 1. Brand IDs
type UserId  = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };

// 2. Schema-validated input
const CreateOrderInput = z.object({
  customer_id: z.string().min(1),
  items: z.array(z.object({
    sku: z.string(),
    quantity: z.number().int().positive(),
  })).min(1),
  currency: z.enum(["USD", "EUR", "KZT"]),
});
type CreateOrderInput = z.infer<typeof CreateOrderInput>;

// 3. Result type for expected outcomes
type CreateError =
  | { code: "validation_failed"; details: z.ZodIssue[] }
  | { code: "customer_not_found" }
  | { code: "out_of_stock"; sku: string };

type CreateResult = Result<{ orderId: OrderId; total_cents: number }, CreateError>;

// 4. The function itself
async function createOrder(rawInput: unknown): Promise<CreateResult> {
  const parsed = CreateOrderInput.safeParse(rawInput);
  if (!parsed.success) {
    return err({ code: "validation_failed", details: parsed.error.issues });
  }
  const input = parsed.data;

  const customer = await db.customers.findById(input.customer_id as UserId);
  if (!customer) return err({ code: "customer_not_found" });

  for (const item of input.items) {
    const stock = await inventory.check(item.sku, item.quantity);
    if (!stock.ok) return err({ code: "out_of_stock", sku: item.sku });
  }

  const order = await db.orders.create(input);  // throws on db failure: unexpected, exception path
  return ok({ orderId: order.id, total_cents: order.total_cents });
}

// 5. Caller switches exhaustively
const result = await createOrder(req.body);
if (!result.ok) {
  switch (result.error.code) {
    case "validation_failed":     return errorResponse("validation_failed", 400, result.error.details);
    case "customer_not_found":    return errorResponse("customer_not_found", 404);
    case "out_of_stock":          return errorResponse("out_of_stock", 422, { sku: result.error.sku });
    default:                      return assertNever(result.error);
  }
}
return Response.json({ id: result.value.orderId, total_cents: result.value.total_cents }, { status: 201 });
```

What this demonstrates: branded IDs prevent cross-type bugs; schema validation at the boundary; Result type for expected failures with discriminated union; exhaustive switch via `assertNever`; exceptions reserved for unexpected DB failures; types narrow correctly through the chain.

## Workflow

1. **Start with the types.** Sketch data shapes and function signatures before logic.
2. **Type external boundaries first.** API responses, env vars, message payloads - validate at the door.
3. **Use discriminated unions for state machines.** `Loading | Loaded | Failed` beats `{ data?, error?, isLoading }`.
4. **Let the compiler find your bugs.** If a refactor produces 30 type errors, fix them; do not silence.
5. **Run `tsc --noEmit` in CI.** Type errors fail the build.

## Verification

```bash
bash skills/backend/forge-typescript/verify/check_ts_discipline.sh path/to/file.ts
```

Flags: `:any`, `as any`, `@ts-ignore`/`@ts-nocheck`, non-null assertions, `JSON.parse` without schema, `Function`/`Object` types.

## When to skip this skill

- Generated code (Prisma client, GraphQL codegen output).
- Very small scripts or repls.
- Vendored third-party code you are not modifying.

## Related skills

- [`forge-validation`](../forge-validation/SKILL.md) - schema-first input validation discipline.
- [`forge-error-handling`](../forge-error-handling/SKILL.md) - Result vs exceptions, observability.
- [`forge-api-design`](../forge-api-design/SKILL.md) - error shapes, status codes, idempotency.
