Blog › ICP guides

TypeScript developer on retainer: type system architecture, compiler configuration, generics and conditional types, and toolchain governance on monthly retainer

August 8, 2026 · ~18 min read

A 12-person product startup had been on TypeScript for two years when the CTO decided to enable strict mode. The codebase was 40,000 lines, originally migrated from JavaScript by adding // @ts-ignore comments liberally and setting "strict": false in the tsconfig. The team knew they needed strict mode — they had already shipped two production bugs that TypeScript’s null checks would have caught at compile time. When a senior engineer ran tsc --strict for the first time, the compiler reported 412 errors across 89 files.

The team stalled. The errors were distributed across the entire codebase — API handlers, data access layers, React components, shared utility modules. Some were trivial (Object is possibly 'undefined' with a straightforward null check fix); others required rethinking an entire module’s interface design to propagate nullability correctly. The CTO’s estimate was that fixing all 412 errors at once would take four to six weeks, block two feature sprints, and still leave the team with an incomplete understanding of the type system patterns needed to prevent the errors from reaccumulating as new code was written.

A fractional TypeScript architect on monthly retainer resolved the situation in a different way. Instead of fixing all 412 errors in one pass, the architect designed an incremental strict mode enablement path: enable strictNullChecks alone first (the most valuable flag, responsible for the majority of real bugs caught), fix the 280 errors it produced over two weeks in priority order by module criticality, then add noImplicitAny in week three, then strictFunctionTypes and noUncheckedIndexedAccess after that. Each flag was enabled in the tsconfig one at a time with a separate PR, making the change reviewable and the error surface manageable. The architect also introduced the branded type pattern, discriminated unions, and proper generic signatures that prevented the underlying design patterns that had caused the original errors from reappearing in new code.

TypeScript developers, TypeScript architects, and TypeScript consultants on monthly retainer — fractional TypeScript engineers, TypeScript migration consultants, and TypeScript platform advisors — do their highest-value work in the type system architecture, compiler configuration, generic and conditional type design, and toolchain governance that produces the type-safe, maintainable codebase the engineering director reports on to the CTO. This guide covers the TypeScript type system in depth, tsconfig compiler configuration, generics and conditional types, module systems and declaration files, and the esbuild/SWC/Vitest toolchain — and how to structure a TypeScript developer retainer that makes the hours behind each type-level function visible.

TypeScript type system deep dive

TypeScript’s type system is structurally typed: two types are compatible if their shapes match, regardless of their names. This is fundamentally different from nominally typed languages like Java or C# where a value of type User is never assignable to a variable of type Account even if both have identical fields. Understanding structural typing — and its deliberate workarounds — is the foundation of TypeScript architecture work.

Structural typing and the branded type pattern

The structural compatibility rule means TypeScript has no objection to this:

type UserId = string;
type OrderId = string;

function getOrder(orderId: OrderId): Order { /* ... */ }

const userId: UserId = "user-123";
getOrder(userId); // No error — UserId and OrderId are both string, structurally identical

The caller passed a UserId where an OrderId was expected, and TypeScript allowed it silently. In a domain with many entity identifier types, this class of bug is common and only surfaces at runtime when the database returns no rows (or worse, the wrong row). The branded nominal type pattern adds a phantom type property that makes two otherwise-identical types structurally distinct:

type UserId  = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };

// Safe constructor functions (the only place you cast):
function toUserId(id: string): UserId   { return id as UserId; }
function toOrderId(id: string): OrderId { return id as OrderId; }

function getOrder(orderId: OrderId): Order { /* ... */ }

const userId = toUserId("user-123");
getOrder(userId);
// Error: Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.
//   Type 'UserId' is not assignable to type '{ readonly __brand: "OrderId"; }'.

The __brand property never exists at runtime — it is purely a type-level marker that TypeScript uses during type checking. The intersection with string preserves all string operations on the branded type. The as UserId cast is confined to the constructor function; all other code receives and passes UserId values through the type system without casting. TypeScript now enforces the identity distinction that the domain model requires.

Discriminated unions and exhaustiveness checking

A discriminated union is a union of object types that share a single literal-typed field — the discriminant — that TypeScript uses to narrow the union in switch statements and if-chains. It is the correct replacement for boolean flag pairs (isRush: boolean; isCancelled: boolean; isPending: boolean) that produce illegal combinations and require defensive checks everywhere:

type OrderStatus =
  | { kind: 'pending' }
  | { kind: 'rush';      priorityLevel: 1 | 2 | 3 }
  | { kind: 'fulfilled'; shippedAt: Date; trackingId: string }
  | { kind: 'cancelled'; reason: string; cancelledAt: Date };

function describeOrder(status: OrderStatus): string {
  switch (status.kind) {
    case 'pending':
      return 'Awaiting processing';
    case 'rush':
      return `Rush order — priority ${status.priorityLevel}`; // priorityLevel available here
    case 'fulfilled':
      return `Shipped ${status.shippedAt.toISOString()} — ${status.trackingId}`;
    case 'cancelled':
      return `Cancelled: ${status.reason}`;
    default:
      // Exhaustiveness check: if a new variant is added to OrderStatus
      // without a corresponding case, this line becomes a compile error.
      const _exhaustive: never = status;
      return _exhaustive;
  }
}

Inside each case branch, TypeScript narrows status to the specific variant: status.priorityLevel is only accessible inside the 'rush' case. The default: const _exhaustive: never = status pattern turns the switch into an exhaustiveness check — if a new variant ({ kind: 'backordered'; eta: Date }) is added to the union without a corresponding case, status in the default branch has type { kind: 'backordered'; eta: Date }, which is not assignable to never, producing a compile error that surfaces immediately.

Template literal types

TypeScript’s template literal types allow constructing string types from other types, enabling string-level type safety for event names, route paths, CSS property names, and API endpoints:

type Entity = 'order' | 'user' | 'product';
type Action = 'created' | 'updated' | 'deleted';

// Generates all 9 combinations: "order:created" | "order:updated" | ... | "product:deleted"
type DomainEvent = `${Entity}:${Action}`;

// EventHandler keys are constrained to valid DomainEvent strings:
type EventHandlers = {
  [K in DomainEvent]: (payload: unknown) => void;
};

// Extract only the "created" events from the union:
type CreatedEvents = Extract<DomainEvent, `${string}:created`>;
// "order:created" | "user:created" | "product:created"

// Generate getter method names from an object type's keys:
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface User { id: string; email: string; name: string; }
type UserGetters = Getters<User>;
// { getId: () => string; getEmail: () => string; getName: () => string; }

The as clause in mapped types (key remapping) enables renaming keys during mapping. The Capitalize<string & K> intersection is necessary because K is constrained to keyof T which includes symbol and number, and Capitalize requires a string argument; the intersection narrows K to the string portion of keyof T.

The infer keyword in conditional types

The infer keyword inside a conditional type’s extends clause introduces a type variable that TypeScript fills in by matching the structure of the checked type. It enables extracting type information from complex types:

// Extract the return type of a function:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

type GetOrderResult = ReturnType<typeof getOrder>; // resolves to Order

// Extract the element type of a Promise:
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;

// Infer from multiple positions in a single conditional:
type FirstAndRest<T extends unknown[]> =
  T extends [infer Head, ...infer Tail] ? { head: Head; tail: Tail } : never;

type Result = FirstAndRest<[string, number, boolean]>;
// { head: string; tail: [number, boolean] }

// TypeScript 4.7+: constrained infer — infer R must extend string:
type StringReturnType<T> = T extends () => infer R extends string ? R : never;

declare function getStatus(): 'active' | 'inactive';
type Status = StringReturnType<typeof getStatus>; // "active" | "inactive"

Constrained inference (infer R extends string, TypeScript 4.7+) prevents the inferred type from being wider than needed when the inferred position must satisfy a constraint. Without the constraint, infer R in a return position infers the full return type including any non-string branches; with the constraint, TypeScript narrows the inferred type to only the string-compatible members.

The satisfies operator and const type assertions

The satisfies operator (TypeScript 4.9+) validates that a value matches a type without widening the inferred type to the annotation type. This distinction matters when the value contains literal types that callers depend on:

type RouteConfig = Record<string, { path: string; method: 'GET' | 'POST' | 'PUT' | 'DELETE' }>;

// With annotation: TypeScript widens the type to RouteConfig.
// config.getOrder.method is string — literal 'GET' is lost.
const config: RouteConfig = {
  getOrder:  { path: '/orders/:id', method: 'GET' },
  createOrder: { path: '/orders',   method: 'POST' },
};
config.getOrder.method; // type: string (widened — not useful for exhaustive checks)

// With satisfies: TypeScript validates the shape against RouteConfig
// but preserves the literal types in the inferred type.
const routes = {
  getOrder:    { path: '/orders/:id', method: 'GET'  },
  createOrder: { path: '/orders',     method: 'POST' },
} satisfies RouteConfig;

routes.getOrder.method; // type: "GET" (literal preserved — useful for exhaustive routing logic)

const type assertions (as const) produce the narrowest possible literal type from a value: arrays become readonly tuples with literal element types; object properties become readonly with literal types; string values become their literal string types instead of string:

const directions = ['north', 'south', 'east', 'west'] as const;
// type: readonly ["north", "south", "east", "west"]
// Without as const: string[]

type Direction = typeof directions[number];
// "north" | "south" | "east" | "west"

// This enables exhaustive switch over the tuple's members:
function navigate(d: Direction) {
  switch (d) {
    case 'north': /* ... */ break;
    case 'south': /* ... */ break;
    case 'east':  /* ... */ break;
    case 'west':  /* ... */ break;
    // Adding a new direction to the tuple produces a compile error here
    // if not handled in the switch — caught at the type level.
  }
}

NoInfer<T> utility type (TypeScript 5.4+)

NoInfer<T> prevents TypeScript from using a specific type parameter position as a source for type inference. It is useful when a generic function has two parameters that share a type parameter, and inference from the first should take precedence over inference from the second:

// Without NoInfer: TypeScript infers T from both `options` and `defaultValue`,
// then unifies the inferred types — potentially widening T to a union.
function createStore<T>(options: T[], defaultValue: T): Store<T> { /* ... */ }

createStore(['a', 'b', 'c'], 'd');
// T is inferred as string (from both sources combined) — 'd' is valid even if 'd'
// is not in the options array.

// With NoInfer: TypeScript only infers T from `options`. `defaultValue` must be
// assignable to the already-inferred T, not contribute to its inference.
function createStore<T>(options: T[], defaultValue: NoInfer<T>): Store<T> { /* ... */ }

createStore(['a', 'b', 'c'] as const, 'd');
// Error: Argument of type '"d"' is not assignable to parameter of type '"a" | "b" | "c"'.
// T is inferred as "a" | "b" | "c" from options; defaultValue must be one of those literals.

tsconfig.json compiler configuration

The tsconfig.json file is both a compiler directive document and a project architecture decision record. The strictness flags enabled, the moduleResolution strategy chosen, and the project reference structure defined in tsconfig.json determine what classes of bugs TypeScript will catch, how the build scales with codebase size, and how the project interoperates with the surrounding toolchain. A TypeScript architect spends a significant fraction of retainer hours on tsconfig.json decisions that are often invisible in the one-line diffs they produce.

The strict flag and what it enables

Setting "strict": true in tsconfig.json is shorthand for enabling seven individual strictness flags simultaneously. Understanding each flag individually matters for incremental enablement:

{
  "compilerOptions": {
    // strict: true enables all seven below:
    "strictNullChecks":          true, // null and undefined are not assignable to other types
    "noImplicitAny":             true, // error when type is inferred as 'any'
    "strictFunctionTypes":       true, // function parameter types are checked contravariantly
    "strictBindCallApply":       true, // bind/call/apply are type-checked
    "strictPropertyInitialization": true, // class properties must be assigned in constructor
    "noImplicitThis":            true, // error when 'this' has type 'any'
    "alwaysStrict":              true, // emits 'use strict' in every file

    // Additional strictness flags NOT included in strict: true (add separately):
    "noUncheckedIndexedAccess":  true, // array[i] returns T | undefined, not T
    "exactOptionalPropertyTypes": true  // { a?: string } ≠ { a?: string | undefined }
  }
}

The incremental enablement order for a codebase already in production: strictNullChecks first (the highest signal-to-noise ratio — every error it surfaces represents a real nullable access that could produce a runtime exception); then noImplicitAny (surfaces type gaps in function parameters and return types); then strictFunctionTypes (catches contravariance violations in callback types that are subtle and real bugs); then noUncheckedIndexedAccess (the noisiest flag — every array subscript now returns T | undefined, requiring null checks throughout; enable last and fix systematically).

strictFunctionTypes enforces contravariant parameter checking for function types. The practical implication: a function that accepts Animal is NOT assignable to a function type that accepts Dog (where Dog extends Animal), even though Dog is a subtype of Animal. This is the correct type-theoretic behavior: a handler typed to accept Dog might call dog.fetch(), which would fail at runtime if the actual argument is a Cat.

noUncheckedIndexedAccess and exactOptionalPropertyTypes

noUncheckedIndexedAccess changes the type of array element access and index signature access from T to T | undefined, forcing explicit null checks on array subscript operations:

// Without noUncheckedIndexedAccess:
const items: string[] = [];
const first: string = items[0]; // No error — items[0] is typed as string
console.log(first.toUpperCase()); // Runtime error: Cannot read properties of undefined

// With noUncheckedIndexedAccess:
const first = items[0]; // type: string | undefined
if (first !== undefined) {
  console.log(first.toUpperCase()); // Safe — TypeScript narrowed to string
}

// The same applies to index signatures:
const map: Record<string, User> = {};
const user = map['unknown-key']; // type: User | undefined (not User)

exactOptionalPropertyTypes makes a subtle but important distinction between an optional property that is absent and one that is explicitly set to undefined. With this flag enabled, { a?: string } means the property a may be absent from the object, but it may NOT be present with the value undefined — that requires the type { a?: string | undefined }. This prevents a common source of bugs in code that uses in operator checks or Object.hasOwn to distinguish absent properties from explicitly-undefined properties.

Incremental compilation and project references

For large TypeScript codebases, the TypeScript architect configures two complementary features for build performance: incremental compilation and project references.

// tsconfig.json (root — with incremental compilation)
{
  "compilerOptions": {
    "incremental": true,
    "tsBuildInfoFile": ".tsbuildinfo",
    // ... other options
  }
}

// packages/shared/tsconfig.json (composite package for project references)
{
  "compilerOptions": {
    "composite": true,           // required for project references
    "declaration": true,         // required for composite
    "declarationMap": true,      // source maps for .d.ts files (enables go-to-definition)
    "outDir": "dist"
  },
  "include": ["src"]
}

// packages/api/tsconfig.json (depends on shared)
{
  "compilerOptions": { "composite": true, "outDir": "dist" },
  "references": [
    { "path": "../shared" }      // tsc --build resolves this dependency graph
  ]
}

// Root tsconfig for tsc --build:
// tsconfig.build.json
{
  "files": [],
  "references": [
    { "path": "packages/shared" },
    { "path": "packages/api"    },
    { "path": "packages/web"    }
  ]
}

With composite: true and references configured, tsc --build tsconfig.build.json understands the dependency graph: it builds shared first (since api and web reference it), then builds api and web in parallel, and on subsequent runs, only rebuilds packages whose source files have changed since the last .tsbuildinfo snapshot. On a 10-package monorepo, this reduces a full type-check from 90 seconds to under 10 seconds for single-package changes.

skipLibCheck, isolatedModules, and moduleResolution

Three tsconfig settings that the TypeScript architect configures deliberately for every production project:

skipLibCheck: true skips type-checking all .d.ts files — both your own generated declarations and third-party @types/ packages. The pragmatic choice for large monorepos: conflicting @types versions from different packages in the dependency tree produce type errors in node_modules that are not actionable. skipLibCheck suppresses these without affecting type-checking of your own source files. skipDefaultLibCheck is the narrower alternative that only skips the TypeScript standard library declarations (lib.dom.d.ts, etc.) — useful for targeted suppression but insufficient for third-party type conflicts.

isolatedModules: true ensures that every TypeScript file can be transpiled to JavaScript independently, without cross-file type information. This is required when using a transpile-only bundler (esbuild, SWC, Babel) that processes each file in isolation without running the TypeScript type checker. Files that violate isolated module constraints produce compile errors: const enum (requires reading the const enum definition from another file), re-exporting a type without the type keyword (export { UserType } must be export type { UserType } so the transpiler knows it can safely erase the export without emitting code).

moduleResolution: "bundler" (TypeScript 5.0+) matches the module resolution behavior of modern bundlers (Vite, esbuild, webpack 5). Unlike "node16", it does not require .js extensions on relative imports; unlike "node", it respects the exports field in package.json for subpath exports. For new projects using Vite or esbuild, "bundler" is the correct moduleResolution setting. For library packages published to npm, "node16" is more appropriate since consumers may use Node.js’s native module resolution.

Generics: constraints, defaults, conditional types, and distributivity

Generics are TypeScript’s mechanism for writing type-safe code that works across multiple types without losing type information. A TypeScript architect on retainer spends significant time designing generic function and interface signatures that catch caller errors at the type level and provide useful type inference for callers — replacing any-typed overly permissive signatures with precisely constrained generics.

Constraints, defaults, and the keyof operator

// K must be a key of T — TypeScript enforces this at every call site:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 'u-1', email: 'ada@example.com', role: 'admin' as const };
const email = getProperty(user, 'email'); // type: string (inferred from T[K])
const role  = getProperty(user, 'role');  // type: "admin" (literal preserved)
getProperty(user, 'missing');             // Error: Argument of type '"missing"' is not
                                          // assignable to parameter of type keyof User

// Default type parameters (TypeScript 2.3+):
interface Repository<T, Id = string> {
  findById(id: Id): Promise<T | null>;
  save(entity: T): Promise<T>;
  delete(id: Id): Promise<void>;
}

// Callers that don't specify Id get string for free:
type UserRepo = Repository<User>;           // Id defaults to string
type OrderRepo = Repository<Order, OrderId>; // OrderId explicitly provided

Distributive conditional types and preventing distribution

When a conditional type’s checked type is a “naked” (un-wrapped) type parameter, TypeScript distributes the conditional over union members automatically. This behavior is powerful for type transformations but can produce surprising results when distribution is not desired:

// Distributive: T is a naked type parameter
type ToArray<T> = T extends any ? T[] : never;

type StrOrNumArrays = ToArray<string | number>;
// Distributes: (string extends any ? string[] : never) | (number extends any ? number[] : never)
// Result: string[] | number[]
// NOT: (string | number)[]

// Preventing distribution: wrap T in a tuple to make it non-naked
type ToArraySingle<T> = [T] extends [any] ? T[] : never;

type Combined = ToArraySingle<string | number>;
// Does NOT distribute: [string | number] extends [any] ? (string | number)[] : never
// Result: (string | number)[]

// A practical use of distributivity — extracting non-nullable members from a union:
type NonNullable<T> = T extends null | undefined ? never : T;

type Cleaned = NonNullable<string | null | undefined | number>;
// Distributes and filters: string | number (null and undefined resolve to never)

Recursive conditional types and variadic tuples

// Recursive conditional type for deep immutability (TypeScript 4.1+):
type DeepReadonly<T> = T extends (infer U)[]
  ? ReadonlyArray<DeepReadonly<U>>
  : T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

type Config = {
  server: { host: string; port: number };
  features: string[];
};
type FrozenConfig = DeepReadonly<Config>;
// {
//   readonly server: { readonly host: string; readonly port: number };
//   readonly features: ReadonlyArray<string>;
// }

// Variadic tuple types (TypeScript 4.0+):
type Concat<A extends unknown[], B extends unknown[]> = [...A, ...B];

type AB = Concat<[string, number], [boolean, Date]>;
// [string, number, boolean, Date]

// Precise curry signature inference with variadic tuples:
function curry<Args extends unknown[], R>(
  fn: (...args: Args) => R
): Args extends [infer First, ...infer Rest]
  ? (first: First) => (...rest: Rest) => R
  : () => R {
  return ((first: unknown) => (...rest: unknown[]) => fn(first, ...rest)) as any;
}

const add = (a: number, b: number) => a + b;
const addCurried = curry(add);
const add5 = addCurried(5); // type: (b: number) => number (inferred precisely)

Module system and declaration files

TypeScript module system configuration is one of the most error-prone areas of a TypeScript codebase when not managed deliberately. The combination of module, moduleResolution, target, and esModuleInterop settings must be internally consistent and must match the expectations of the runtime environment or bundler consuming the compiled output.

ESM vs CommonJS and the dual-package hazard

// tsconfig.json for a Vite/esbuild frontend project (ESM-first):
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "target": "ES2022",
    "isolatedModules": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true
  }
}

// tsconfig.json for a Node.js CommonJS backend service:
{
  "compilerOptions": {
    "module": "CommonJS",
    "moduleResolution": "node",
    "target": "ES2022",
    "esModuleInterop": true
  }
}

// tsconfig.json for a library published to npm (Node16 ESM):
{
  "compilerOptions": {
    "module": "Node16",
    "moduleResolution": "node16",
    "target": "ES2020",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

The dual-package hazard: when a library ships both a CommonJS build and an ESM build (using the exports field in package.json to direct Node.js to the correct build based on the importer’s context), an application that imports the library via both a CJS-requiring dependency and an ESM-requiring dependency will load two separate instances of the library into the same process. Singletons (a global event bus, a database connection pool, a Zustand store) that rely on module-level state will have two independent instances, producing bugs where state changes in one instance are invisible to code holding a reference to the other instance. The TypeScript architect accounts for this when recommending library distribution strategies.

Declaration files: .d.ts authoring and ambient modules

// Generate .d.ts files from source TypeScript:
// tsc --declaration --emitDeclarationOnly --outDir dist/types

// Ambient module declaration for a Webpack/Vite asset import:
// global.d.ts
declare module '*.svg' {
  const content: string;
  export default content;
}

declare module '*.png' {
  const content: string;
  export default content;
}

// Module augmentation — extending third-party types without forking @types:
// Extends Express Request to include an authenticated user (after auth middleware runs):
// src/types/express.d.ts
import type { AuthenticatedUser } from '../domain/user';

declare module 'express' {
  interface Request {
    user?: AuthenticatedUser;
  }
}

// Now throughout the codebase:
app.get('/profile', (req, res) => {
  if (!req.user) { res.status(401).json({ error: 'unauthenticated' }); return; }
  res.json({ id: req.user.id, email: req.user.email }); // req.user is AuthenticatedUser
});

Declaration file authoring for internal JavaScript libraries is a recurring retainer task. When a team has shared JavaScript utilities (a custom logger, an internal SDK, a legacy module that predates the TypeScript migration) without TypeScript types, the TypeScript architect writes .d.ts files that describe the module’s public API precisely, enabling TypeScript’s type checker to validate usage of the library without requiring the library itself to be rewritten in TypeScript. A well-authored .d.ts file for a 500-line internal SDK typically takes 6 to 10 hours to write correctly, accounting for all exported functions, their generic signatures, overloads, and callback types.

Toolchain: esbuild, SWC, ts-node, tsx, and Vitest

The TypeScript toolchain landscape in 2026 has settled around a clear separation of concerns: TypeScript (tsc) is responsible for type checking, and a faster native-code transpiler (esbuild or SWC) is responsible for stripping types and producing JavaScript output. The TypeScript architect configures this separation explicitly, ensuring that the CI type-check gate and the development-speed build pipeline are complementary rather than duplicating work.

esbuild for transpile-only bundling

# Development build: esbuild strips types and bundles in <1 second for most projects.
# Does NOT type-check — that is tsc's job.
esbuild src/index.ts \
  --bundle \
  --platform=node \
  --target=node20 \
  --outfile=dist/index.js \
  --sourcemap \
  --external:./node_modules/*

# Or for a browser bundle:
esbuild src/app.tsx \
  --bundle \
  --platform=browser \
  --target=es2020 \
  --outdir=dist \
  --sourcemap \
  --minify

# CI type-check gate (separate from the build):
tsc --noEmit
# Reports all type errors; exits non-zero if any found; does not produce output files.

The 10–100x speed advantage of esbuild over tsc --build comes from esbuild’s architecture: it is written in Go, parallelizes across all CPU cores, and never runs type inference — it treats TypeScript as JavaScript with syntax to strip. For a 50,000-line TypeScript codebase, tsc --build might take 45 seconds; esbuild completes the same transpilation in under 400ms. The tradeoff is that esbuild provides no type-checking; an uncaught any propagation or a missing property access will not be caught by esbuild. The CI gate (tsc --noEmit) must run on every PR to catch type errors that esbuild ignores.

SWC, ts-node, and tsx

SWC (Speedy Web Compiler) is a Rust-based alternative to esbuild for TypeScript transpilation. It is used in Next.js 13+ as the default compiler (replacing Babel) and is available standalone via @swc/core and @swc/cli. SWC and esbuild have comparable performance characteristics; the choice between them is typically determined by ecosystem compatibility rather than speed.

For development workflows that require running TypeScript directly in Node.js without a prior build step, the tool choice matters:

# ts-node: uses the TypeScript compiler. Slower (type-checks on startup).
# Suitable for scripts where you want type errors to surface at runtime.
npx ts-node src/migrate.ts

# tsx: uses esbuild. Fast, ESM-aware, no type-checking.
# Suitable for development server hot-reload and CLI scripts where startup time matters.
npx tsx src/server.ts
npx tsx watch src/server.ts  # watches for changes and restarts

# package.json scripts:
{
  "scripts": {
    "dev":        "tsx watch src/server.ts",
    "build":      "esbuild src/server.ts --bundle --platform=node --outfile=dist/server.js",
    "typecheck":  "tsc --noEmit",
    "test":       "vitest run",
    "test:watch": "vitest"
  }
}

Vitest for TypeScript-native testing

Vitest is the testing framework that the TypeScript architect recommends for projects already using Vite. It is zero-configuration for TypeScript — no ts-jest transform, no Babel config, no jest.config.js module transformation setup. Vitest uses Vite’s esbuild-based TypeScript transformation natively:

// src/domain/order.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { processOrder } from './order';
import { pricingService } from '../services/pricing';

vi.mock('../services/pricing', () => ({
  pricingService: {
    calculate: vi.fn(),
  },
}));

describe('processOrder', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('returns a fulfilled order when pricing succeeds', async () => {
    vi.mocked(pricingService.calculate).mockResolvedValueOnce({ total: 99.99 });

    const result = await processOrder({ id: toOrderId('ord-1'), items: ['sku-a'] });

    expect(result).toMatchInlineSnapshot(`
      {
        "kind": "fulfilled",
        "orderId": "ord-1",
        "total": 99.99,
      }
    `);
  });

  it('returns a cancelled order when pricing throws', async () => {
    vi.mocked(pricingService.calculate).mockRejectedValueOnce(
      new Error('pricing service unavailable')
    );

    const result = await processOrder({ id: toOrderId('ord-2'), items: ['sku-b'] });

    expect(result.kind).toBe('cancelled');
  });
});

vi.mocked(fn) is a type-safe wrapper that asserts fn is a mock and provides the full MockInstance type — no casting needed. toMatchInlineSnapshot() serializes the actual value into the test file on first run and thereafter compares against the serialized snapshot, making regression detection convenient without a separate __snapshots__ directory. Vitest’s --coverage flag integrates with @vitest/coverage-v8 for V8-native coverage reporting without Istanbul instrumentation.

TypeScript retainer work patterns: what gets underlogged

The challenge with TypeScript retainer work is that its most valuable outputs are type-level changes that produce no runtime difference. A TypeScript architect who spends 11 hours converting a module from any-typed functions to precise discriminated unions and branded types has prevented bugs that would have taken 4 to 8 hours each to diagnose and fix in production — but the retainer invoice line shows 11 hours of “TypeScript refactoring” against zero visible new features.

Type-level refactoring

Type-level refactoring is the process of converting any types to precise interfaces, adding discriminated unions to replace boolean flag pairs, narrowing function signatures with generics, and adding branded types for domain identifiers. A representative refactoring engagement:

// Before: any-typed, flags everywhere, identity confusion possible
interface Order {
  id: string;           // Could be passed where CustomerId is expected
  customerId: string;   // Same type as id — no compiler protection
  isRush: boolean;
  isFulfilled: boolean;
  isCancelled: boolean; // Could be true simultaneously with isFulfilled
  metadata: any;        // No structure enforced
}

function processOrder(order: any): any { /* ... */ }

// After: precise types, discriminated status, branded IDs, inferred return type
type OrderId    = string & { readonly __brand: 'OrderId' };
type CustomerId = string & { readonly __brand: 'CustomerId' };

type OrderMetadata = {
  source: 'web' | 'mobile' | 'api';
  ipAddress?: string;
  userAgent?: string;
};

type OrderStatus =
  | { kind: 'pending' }
  | { kind: 'rush'; priorityLevel: 1 | 2 | 3 }
  | { kind: 'fulfilled'; shippedAt: Date; trackingId: string }
  | { kind: 'cancelled'; reason: string; cancelledAt: Date };

interface Order {
  id: OrderId;
  customerId: CustomerId;
  status: OrderStatus;
  metadata: OrderMetadata;
}

type ProcessOrderResult =
  | { success: true;  order: Order }
  | { success: false; error: string; code: 'PRICING_UNAVAILABLE' | 'INVENTORY_EMPTY' };

function processOrder(order: Order): Promise<ProcessOrderResult> { /* ... */ }

The refactored version makes three classes of bugs impossible at the type level: passing a CustomerId where an OrderId is expected (now a compile error), setting isFulfilled and isCancelled simultaneously (structurally impossible — the order can only have one status with a single kind), and calling result.order without first checking result.success (TypeScript narrows the discriminated union and only provides order in the success: true branch). The runtime behavior is identical to the any-typed version. The type-level refactoring typically takes 8 to 20 hours per major module and is invisible in the reduced bug rate that follows.

tsconfig strict mode migration

The 12-person startup’s 412 compiler errors represented roughly 25 to 30 hours of careful work spread across the incremental enablement path. The work was invisible in the tsconfig.json one-line diff that added "strictNullChecks": true. The hours went to: reading each error, classifying it (trivial null check addition vs. interface redesign required), prioritizing by module criticality (data access layer before React components), writing the null check patterns that would not accumulate tech debt (if (!value) throw new Error(...) vs. propagating the nullable type up the call chain to where it should be handled), and code-reviewing the fixes to ensure the null handling was semantically correct rather than just syntactically satisfying the compiler. Fifteen to thirty hours per project is the typical range for a medium-sized TypeScript codebase migrating to strict mode, depending on the depth of any usage and the degree of nullable propagation required.

Declaration file authoring

Writing .d.ts files for internal JavaScript libraries is perhaps the most invisible TypeScript retainer work of all. The output is an index.d.ts file; the input is typically a 500- to 2,000-line JavaScript module with no type annotations and informal JSDoc comments of variable accuracy. The TypeScript architect reads the JavaScript source, infers the intended types from usage context and variable names, designs the generic signatures that correctly capture the function’s polymorphic behavior, and writes the declaration file. Common challenges: JavaScript functions with overloaded behavior based on argument count or type (requiring TypeScript overload signatures); callback-based APIs that predate Promises (requiring correct void vs. never return type handling); and modules that export mutable state objects alongside pure functions (requiring careful choice between interface and type for the exported types). Six to fourteen hours per internal package is the typical range.

HourTab for TypeScript developer retainers

TypeScript developer retainer work produces type-safe codebases, faster CI feedback cycles, a strict-mode tsconfig that catches null access bugs before they reach production, and a monorepo build that completes in 8 seconds instead of 90. The hours behind each outcome — the 11 hours of type-level refactoring that caught three identity-confusion bugs and 18 null safety gaps, the 28 hours of incremental strict mode enablement across 89 files, the 10 hours of declaration file authoring for the internal event SDK — are not visible to the CTO or engineering director without a work log that connects each hour block to the specific TypeScript platform function performed.

HourTab gives TypeScript architects and TypeScript consultants a retainer dashboard their engineering directors can bookmark without creating an account: the month’s committed hours, the hours consumed, and the work log entries that connect each block to the type-level refactoring session, the tsconfig migration milestone, the declaration file authoring engagement, or the Vitest configuration sprint. When the VP Engineering can see that 11 of the month’s 30 retainer hours went to type-level refactoring of the order processing module and 9 went to enabling strictNullChecks and fixing the resulting errors in the data access layer, the retainer renewal conversation is grounded in the actual distribution of TypeScript platform advisory work rather than an abstract sense of whether the compiler investment produced value.

The retainer model fits TypeScript architecture consulting because TypeScript codebases are living systems: every new module added by a developer without type system expertise reintroduces any annotations that undo previous type-level work; every dependency upgrade brings updated @types packages that may break existing type assumptions; every TypeScript version release (historically two to three per year) introduces new type system features (satisfies in 4.9, NoInfer in 5.4, moduleResolution: bundler in 5.0) that the TypeScript architect evaluates for adoption; and the monorepo project reference graph requires ongoing maintenance as packages are added, split, or merged. A monthly hour commitment provides the TypeScript architect’s sustained availability across the full type system maintenance and evolution calendar.

For TypeScript consultants documenting retainer work, sharing a live hours dashboard replaces the weekly status email: the client sees the current month’s hour consumption and the work log entries that narrate what each block of TypeScript advisory hours accomplished.

Frequently asked questions

What does a TypeScript developer on retainer typically do?

A TypeScript developer or TypeScript architect on monthly retainer provides ongoing type system advisory and development across four principal service areas. Type system architecture and refactoring: converting any-typed interfaces to precise discriminated unions, adding branded nominal types for domain identifiers (UserId, OrderId, CustomerId) that prevent identity confusion at compile time, designing mapped types and conditional types that derive secondary interfaces from a single source-of-truth type definition, applying the satisfies operator to preserve literal types in configuration objects, and authoring declaration files for JavaScript packages that lack community types. tsconfig.json compiler configuration and migration: enabling strict mode incrementally across an existing codebase, setting up composite project references for TypeScript monorepos, and configuring isolatedModules for compatibility with esbuild or SWC transpile-only bundlers. Generics, conditional types, and type-level programming: designing generic function signatures with keyof and extends constraints, implementing distributive conditional types for type transformations over union members, and writing recursive conditional types for deep structural transformations. Toolchain governance: configuring esbuild or SWC for fast transpile-only bundling paired with tsc --noEmit as the CI type-checking gate, setting up Vitest for zero-config TypeScript-native testing, and maintaining moduleResolution settings that match the project’s bundler.

What TypeScript work is most commonly underlogged in a retainer?

The most systematically underlogged categories are type-level refactoring (converting any types to precise interfaces, adding discriminated unions to replace boolean flags, narrowing function signatures with generics — produces no runtime change but significantly improves downstream developer experience and catches bugs at compile time; typically 8 to 20 hours per major module invisible in the reduced bug rate); tsconfig migration (enabling strict mode incrementally, fixing the resulting errors across potentially hundreds of files, setting up project references for a monorepo — typically 15 to 30 hours per project invisible in the tsconfig.json diff); declaration file authoring for internal libraries (writing .d.ts files for JavaScript packages that don’t have TypeScript types — typically 6 to 14 hours per package invisible in the index.d.ts file); and toolchain configuration (setting up esbuild or SWC, configuring moduleResolution: bundler for Vite compatibility, adding isolatedModules enforcement, wiring Vitest — typically 4 to 12 hours per project invisible in the package.json scripts). Detailed work log entries that capture the specific type errors surfaced and the architectural decisions made connect the invisible TypeScript platform investment to its concrete outcomes.

What should a TypeScript developer retainer agreement include?

TypeScript developer retainer agreements should specify: scope boundary between feature development, type system advisory, code review, and tsconfig migration (type system advisory and code review produce no deployable artifact — define these explicitly as in-scope functions with their own hour allocation); repository access level required (read access for type audit and code review; write access for pull request authorship; CI pipeline access for tsc --noEmit gate configuration); toolchain scope (whether the retainer covers esbuild or SWC configuration, Vitest setup, moduleResolution migration, and isolatedModules enforcement); IP ownership for type definitions, declaration files, and generic utility type libraries authored during the engagement; tsconfig governance scope (who owns incremental strictness enablement decisions, project reference structure for monorepo packages, and tsBuildInfoFile location); and a shared work log documenting each type-level refactoring session, tsconfig migration milestone, declaration file authoring engagement, and toolchain configuration sprint. Monthly retainer amounts for TypeScript developer advisory and architecture consulting typically range from $5,500 to $13,000 per month for code review and type system advisory retainers, increasing to $12,000 to $28,000 per month for full-stack TypeScript architecture consulting at scale.

What are typical retainer rates for TypeScript developers and TypeScript architects?

Entry-level TypeScript developers with 1 to 3 years of experience, TypeScript proficiency, and React or Node.js integration skill typically bill $75 to $130 per hour, with monthly retainers running 10 to 18 hours for code review and advisory work. Mid-level TypeScript engineers with 3 to 8 years of experience, expertise in type system design (generics, mapped types, conditional types, discriminated unions), monorepo tooling, and compiler configuration, typically bill $125 to $220 per hour, with monthly retainers running 15 to 30 hours. Senior TypeScript architects with 8 to 14 years of experience, expertise in type-level programming, compiler plugin authorship, and DefinitelyTyped contributions, typically bill $185 to $350 per hour, with monthly retainers running 20 to 40 hours. TypeScript consulting firms and specialized frontend architecture consultancies typically bill $155 to $270 per hour. Monthly retainer amounts range from $5,500 to $13,000 per month for code review and type system advisory retainers, increasing to $12,000 to $28,000 per month for full-stack TypeScript architecture consulting engagements covering strict mode migration, monorepo project reference architecture, and toolchain governance.

How should TypeScript developer retainer hours be logged?

Work log entries should capture the advisory category (type system architecture, tsconfig migration, generics design, declaration file authoring, toolchain configuration, code review), the specific module or package, the task, and the finding or deliverable. Example: “Type System Architecture — order-service, src/domain/. Task: replace any-typed order processing pipeline with discriminated unions and branded domain types. Work: audited 340 lines across order.ts, pricing.ts, fulfillment.ts — found 23 any annotations, 4 boolean flag pairs, and OrderId/CustomerId typed as plain string making them interchangeable in function calls — 2 hours; introduced branded types and applied across all domain interfaces; compiler immediately flagged 3 call sites passing CustomerId where OrderId was expected — 2 hours; replaced boolean flags with OrderStatus discriminated union with exhaustiveness check — 4 hours; enabled strictNullChecks, fixed 18 resulting errors in repository layer — 3 hours. Total: 11 hours. Compile-time bugs surfaced: 3 identity confusion errors, 18 null safety gaps. Runtime changes: zero.” Entries that document the specific type errors caught connect the 11 hours of type-level work to the bugs it prevented, making the TypeScript retainer investment legible to the engineering director reviewing the work log.