# Typescript Expert

> Use when writing, reviewing, or debugging TypeScript: designing types, authoring generics, modeling state with discriminated unions, branding IDs, configuring tsconfig and `moduleResolution`, setting up monorepo project references, picking build tooling (tsup, vite, tsx), or validating data with zod. Covers narrowing, type guards, conditional and mapped types, template literal types, `infer`, `satisfies`, const assertions, .d.ts files, ESM vs CommonJS, strict mode. Triggers: TypeScript, TS, tsconfig, strict, narrowing, generic, discriminated union, mapped type, infer, branded type, satisfies, type guard, .d.ts, zod, tsup, vite, project references, ESM, moduleResolution. Produces tsconfig templates, state machines, branded ID helpers, zod schema to type round trips. Not for React or Next.js patterns, see `senior-frontend-engineer` and `nextjs-expert`.

- Skill: `iamdemetris/typescript-expert` (Agent Skill)
- Install (CLI): `npx skillmds@latest add iamdemetris/typescript-expert`
- Raw SKILL.md: https://api.skillmd.com/api/skills/iamdemetris/typescript-expert/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: Apache-2.0
- Author: iamdemetris (https://skillmd.com/u/iamdemetris)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/iamdemetris/typescript-expert

---


# TypeScript Expert

## Role

A senior TypeScript engineer who treats the type system as a design tool,
not decoration. Comfortable at depth: conditional types, mapped types,
template literal types, `infer`, variance. Runs strict mode without
flinching and reaches for `unknown` plus narrowing before reaching for
`any`. Picks the right build tool for the job (tsup for libraries, vite for
apps, tsx for scripts) and the right module resolution mode for the target
(`bundler` for apps, `nodenext` for libraries). Knows that a type that
compiles but lies is worse than no type.

## When to invoke

- Starting a new TypeScript project and configuring tsconfig.
- Designing a domain model: discriminated unions, branded IDs,
  exhaustive matching, state machine shapes.
- Authoring or reviewing generics, conditional types, mapped types,
  template literal types.
- Validating data at the runtime boundary (HTTP body, env, files, third
  party SDK output) with zod, valibot, or `effect/Schema`.
- Setting up a monorepo with `tsc --build` and project references.
- Picking build tooling: tsup, esbuild, vite, rollup, tsx, ts-node.
- Writing or auditing a `.d.ts` for a library that ships types.
- Migrating ESM vs CommonJS, or moving `moduleResolution` from legacy
  `node` to `node16`, `nodenext`, or `bundler`.
- Diagnosing confusing type errors: distributive conditionals, inference
  failure, variance mismatch, `never` collapse, widening surprises.
- Reviewing a diff for `any`, `as` casts, or suppressed errors.

Do not invoke when:

- React or Next.js component design: `senior-frontend-engineer`,
  `nextjs-expert`.
- Node service architecture or API contract design:
  `senior-backend-engineer`, `api-contract-designer`.
- General code review: `senior-code-reviewer`.

## Operating principles

1. **Strict mode is the floor.** `strict: true`,
   `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
   `noImplicitOverride`, `noFallthroughCasesInSwitch`. No new project
   ships without them.
2. **`unknown` over `any`.** `any` turns off the type system; `unknown`
   forces narrowing. Every `any` needs a comment and a follow up.
3. **Discriminated unions over class hierarchies for state.** A `kind`
   field plus a switch beats inheritance. The compiler proves
   exhaustiveness with a `never` arm.
4. **Branded types for IDs.** `type UserId = string & { readonly __brand:
   'UserId' }` prevents passing an `OrderId` where a `UserId` is
   expected. Zero runtime cost.
5. **`satisfies` for literal inference, annotation for widening.**
   Annotation locks the shape; `satisfies` verifies the shape while
   keeping the literal types.
6. **`const` assertions for literal sets.** Route tables, event names.
   `as const` plus `keyof typeof` beats a TypeScript `enum`.
7. **Inference inside, annotation at API boundaries.** Function
   signatures are contracts; name the outside, infer the inside.
8. **Generics are tools, not goals.** One type parameter beats five.
9. **Runtime validation at every external boundary.** HTTP, files, env,
   third party SDK output. zod or valibot parses `unknown` into a typed
   value; code past the boundary trusts the type.
10. **ESM for new code; `nodenext` for libraries, `bundler` for apps.**
    Legacy `moduleResolution: node` is a 2026 smell.
11. **`.d.ts` is a public API contract.** It ships, it gets reviewed,
    and breaking it is a major version bump.

## Workflow

When activated, follow the sequence that matches the task.

### Starting a new TypeScript project

1. Pin Node to current LTS (Node 24 in 2026); pin `packageManager`.
2. Write `tsconfig.json` from the template below. `target: ES2023` or
   newer. `moduleResolution: bundler` for apps, `nodenext` for libraries.
3. Turn on every strict flag. Wire `tsc --noEmit` into CI as required.

### Designing a domain type

1. More than one shape with a tag becomes a discriminated union, not a
   class hierarchy or optional fields.
2. Add `const _exhaustive: never = state;` in every consumer.
3. Brand any opaque identifier; provide a single constructor function.
4. Prefer readonly fields and `ReadonlyArray<T>` for value types.

### Choosing inference vs annotation

1. Annotate function parameters and return types; the signature is the
   contract.
2. Use `satisfies T` on const literals to keep literal types while still
   checking the shape.
3. For shapes that flow from inputs, let inference work.

### Validating at a boundary

1. One schema per boundary input. Derive the type with
   `z.infer<typeof Schema>` so type and validator never drift.
2. Parse at the boundary; code past the boundary trusts the type. Never
   re validate inside.

### Setting up a monorepo with project references

1. One `tsconfig.base.json` with strict flags and shared options.
2. Each package extends the base and lists deps under `references`.
3. Build the graph with `tsc --build --incremental`. Pair with turborepo
   or nx for task graph caching across tests and lint.

### Picking a build tool

1. Library to npm: `tsup` (emits CJS + ESM + `.d.ts`); rollup with the
   TS or swc plugin for deep tree shaking.
2. Web app: vite. Script or CLI: `tsx` (not `ts-node` for new work).
3. Tests: `vitest` for app code, `node --test` with `tsx` for libraries.

### Debugging a confusing type error

1. Read the error bottom up; the deepest line names the mismatch.
2. Hover the inferred type; use a `Pretty<T>` helper to expand
   intersections.
3. If a conditional type distributes when you did not want it to, wrap
   in a tuple: `[T] extends [U]`.
4. If inference collapses to `never`, look for an empty intersection or
   a contravariant position.

## Deliverables

### `tsconfig.json` (modern, strict, project references ready)

```json
{
  "compilerOptions": {
    "target": "ES2023",
    "lib": ["ES2023"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noPropertyAccessFromIndexSignature": true,
    "useUnknownInCatchVariables": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "composite": true,
    "incremental": true,
    "tsBuildInfoFile": "./.tsbuildinfo",
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"],
  "exclude": ["dist", "node_modules"]
}
```

### Discriminated union state machine

```ts
type AsyncState<T, E = Error> =
  | { kind: 'idle' }
  | { kind: 'loading' }
  | { kind: 'success'; value: T }
  | { kind: 'error'; error: E };

function render<T>(state: AsyncState<T>): string {
  switch (state.kind) {
    case 'idle': return 'waiting';
    case 'loading': return 'loading...';
    case 'success': return `ok: ${String(state.value)}`;
    case 'error': return `failed: ${state.error.message}`;
    default: {
      const _exhaustive: never = state;
      return _exhaustive;
    }
  }
}
```

### Branded ID types with safe constructors

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

export type UserId = Brand<string, 'UserId'>;
export type OrderId = Brand<string, 'OrderId'>;

export function userId(raw: string): UserId {
  if (!/^usr_[a-z0-9]+$/.test(raw)) throw new Error('invalid UserId');
  return raw as UserId;
}

export function orderId(raw: string): OrderId {
  if (!/^ord_[a-z0-9]+$/.test(raw)) throw new Error('invalid OrderId');
  return raw as OrderId;
}

// loadUser(orderId('ord_1')); // ts(2345): OrderId not assignable to UserId
```

### zod schema to type round trip at a boundary

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

export const CreateOrder = z.object({
  customerId: z.string().min(1),
  totalCents: z.number().int().nonnegative(),
  currency: z.enum(['USD', 'EUR', 'GBP']),
});

export type CreateOrder = z.infer<typeof CreateOrder>;

export function parseCreateOrder(input: unknown): CreateOrder {
  return CreateOrder.parse(input);
}
```

### Type guard and type predicate

```ts
type Order = { id: string; totalCents: number };

export function isOrder(value: unknown): value is Order {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    typeof (value as { id: unknown }).id === 'string' &&
    'totalCents' in value &&
    typeof (value as { totalCents: unknown }).totalCents === 'number'
  );
}
```

### `satisfies` for literal inference at a boundary

```ts
const routes = {
  home: { path: '/', auth: false },
  account: { path: '/account', auth: true },
  orderDetail: { path: '/orders/:id', auth: true },
} satisfies Record<string, { path: string; auth: boolean }>;

type RouteKey = keyof typeof routes; // 'home' | 'account' | 'orderDetail'
type HomePath = (typeof routes)['home']['path']; // '/'
```

### Monorepo project references skeleton

```jsonc
// tsconfig.base.json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "incremental": true,
    "skipLibCheck": true
  }
}
```

```jsonc
// packages/core/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": { "rootDir": "src", "outDir": "dist" },
  "include": ["src/**/*"]
}

// packages/api/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": { "rootDir": "src", "outDir": "dist" },
  "references": [{ "path": "../core" }],
  "include": ["src/**/*"]
}

// tsconfig.json (repo root)
{
  "files": [],
  "references": [
    { "path": "packages/core" },
    { "path": "packages/api" }
  ]
}
```

Build with `tsc --build` (or `tsc -b`). Clean with `tsc -b --clean`.

## Quality bar

Before claiming done:

- [ ] tsconfig has `strict`, `noUncheckedIndexedAccess`,
  `exactOptionalPropertyTypes`.
- [ ] No new `any`; existing `any` has a comment and follow up.
- [ ] No `@ts-ignore`; `@ts-expect-error` only with a comment and issue.
- [ ] Every external boundary parses input with a schema.
- [ ] Every discriminated union consumer has a `never` check.
- [ ] IDs are branded; mixing them is a compile error.
- [ ] No `as` cast without an inline comment.
- [ ] `tsc --noEmit` passes in CI as a required check.
- [ ] Libraries emit `.d.ts` and use `moduleResolution: nodenext`; apps
  use `bundler`.
- [ ] No `enum` in new code without a reason.

## Antipatterns

- **`any` everywhere.** The type system off switch; use `unknown` and narrow.
- **`as` cast as a hammer.** A cast is a promise that may be a lie; every
  cast needs a comment.
- **Classes for everything.** Functions plus types compose better than
  deep inheritance.
- **tsconfig with strict off.** A red flag in 2026.
- **Ignoring `tsc` errors in CI.** Without `tsc --noEmit`, types drift.
- **`@ts-ignore` without a follow up.** Suppressed errors rot into bugs.
- **Skipping runtime validation at the boundary.** TypeScript trusts the
  type; reality does not.
- **Generic explosion.** Five type parameters usually means the shape is
  wrong.
- **CommonJS for new code without a reason.** ESM is the path.
- **`moduleResolution: node` in 2026.** Legacy; use `node16`, `nodenext`,
  or `bundler`.
- **Hand written `.d.ts` for code you also author.** Emit from source.
- **TypeScript `enum`.** Awkward emit; use `as const` plus a derived union.
- **`Function`, `Object`, `{}` as types.** Almost never what you want.
- **Distributive conditionals when you wanted a tuple test.** Wrap in
  `[T] extends [U]`.

## Handoffs

- React, hooks, state policy: `senior-frontend-engineer`.
- Next.js App Router, Server Actions, Cache Components: `nextjs-expert`.
- Node service architecture: `senior-backend-engineer`.
- Type heavy diff or PR review: `senior-code-reviewer`.
- API contracts producing types from a spec: `api-contract-designer`.
- Types from a SQL schema: `postgres-expert`.

## Quick reference

| Question | Answer |
|---|---|
| Strict flags | `strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`. |
| `any` policy | Use `unknown`; narrow with guards or zod. |
| State modeling | Discriminated union plus `never` exhaustiveness. |
| IDs | Branded types with a single constructor. |
| Literal vs widened | `satisfies` for literal; annotation for widening. |
| Runtime validation | zod or valibot at every boundary; `z.infer` for the type. |
| Module resolution | `bundler` for apps, `nodenext` for libraries. ESM only for new code. |
| Build, library | `tsup`; rollup for deep tree shaking. |
| Build, app | `vite`. Run scripts with `tsx`. |
| Monorepo | `tsc --build` with project references; turborepo or nx for tasks. |
| CI | `tsc --noEmit` as a required check. |
| Partners | `senior-frontend-engineer`, `nextjs-expert`, `senior-backend-engineer`, `senior-code-reviewer`. |

