Published on

TypeScript at Platform Scale: Stopping the Any-Bleed

Type safety is a platform feature, not a library configuration. It must be governed as deliberately as API lifecycles or SLOs. Any/unknown creep is entropy: it accumulates, hides risk, and eventually explodes during refactors when it’s most expensive to fix.

Domain types, DTOs, and explicit mappers

Separating domain models from transport-layer DTOs is non-negotiable at platform scale. Domain types encode invariants and business meaning; DTOs reflect wire constraints and versioned contracts. Never reuse DTOs inside the core: map them, validate them, and keep boundaries obvious.

This discipline feels boring at first but pays back in weeks, not months. 🙂

Example pattern:

// Transport (versioned, external)
export type UserDTOv1 = {
  id: string
  email: string
  created_at: string // ISO timestamp
}

// Domain (internal, invariant-rich)
export type User = {
  userId: string
  email: `${string}@${string}`
  createdAt: Date
}

export function mapUserDTOv1ToDomain(dto: UserDTOv1): User {
  return {
    userId: dto.id,
    email: dto.email,
    createdAt: new Date(dto.created_at),
  }
}

Key rules:

  • Prefer satisfies and template literal types for semantic constraints.
  • Keep mappers small, pure, and colocated with their boundary.
  • Validate transport on ingress; normalize to domain as early as possible.
  • Egress performs the inverse mapping explictly.

When DTO evolution diverges from domain semantics (it will), the mapper isolates change and stops semantic leakage. It also makes failure modes observable and testable seperately.

Linting/forbids: eliminate the escape hatches

Your linter and compiler are policy engines. Encode what “good” looks like and make violations impossible to miss:

  • Ban comments: @ts-ignore, @ts-nocheck, @ts-expect-error (outside test files)
  • Enable strictness everywhere: strict: true, noUncheckedIndexedAccess: true, noImplicitOverride: true
  • Exhaustiveness: enforce @typescript-eslint/switch-exhaustiveness-check
  • Zero tolerance for any/unknown escape: prefer domain unions and never fallthroughs

Minimal config sketch:

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "forceConsistentCasingInFileNames": true,
    "useUnknownInCatchVariables": true
  }
}
// .eslintrc.cjs (excerpt)
{
  "rules": {
    "@typescript-eslint/ban-ts-comment": ["error", { "ts-ignore": "allow-with-description" }],
    "@typescript-eslint/no-explicit-any": "error",
    "@typescript-eslint/switch-exhaustiveness-check": "error"
  }
}

Feels brutal but healthy. 😅

Versioning: DTO evolution, codemods, deprecation windows

Transport shapes change faster than domain truths. Treat DTOs as versioned artifacts with explicit adapters and deprecation windows. Keep old readers, prefer new writers, and provide codemods when callers must migrate.

// Transport union of versions
export type UserDTO =
  | ({ version: 1 } & UserDTOv1)
  | ({ version: 2 } & {
      id: string
      email: string
      created_at: string
      status: "active" | "suspended"
    })

export function toDomain(dto: UserDTO): User {
  switch (dto.version) {
    case 1:
      return mapUserDTOv1ToDomain(dto)
    case 2: {
      const domain = mapUserDTOv1ToDomain(dto)
      // status handled elsewhere for now
      return domain
    }
    default: {
      const _exhaustive: never = dto
      return _exhaustive
    }
  }
}

Codemods should move call sites away from deprecated fields and onto new shapes. Deprecations that never expire are debt with interest; put a date on them and enforce removal in CI to guarentee follow-through.

Type tests: tsd, generative examples, boundary assertions

If it isn’t tested, it’s a rumor. Type-level tests make agreements executable. Use tsd, vitest’s expectTypeOf, or dtslint to lock contracts.

// tests/user.mapper.types.test.ts
import { expectTypeOf, describe, it } from 'vitest'
import { mapUserDTOv1ToDomain, UserDTOv1, User } from './user'

describe('User mapper types', () => {
  it('maps transport to domain', () => {
    const dto: UserDTOv1 = { id: 'u_1', email: 'a@b.com', created_at: new Date().toISOString() }
    const user = mapUserDTOv1ToDomain(dto)
    expectTypeOf(user).toEqualTypeOf<User>()
  })
})

Boundary assertions are equally useful at API edges:

// Asserting exhaustive handling
function render(u: User | null): string {
  if (u === null) return '—'
  // exhaustive by discriminant
  const _: never = ((): never => { throw new Error('unreachable') })()
  return `${u.userId} <${u.email}>`
}

It’s the first time the type system felt like a safety net instead of a chore. 🙌

Living docs: generated d.ts, typed stories and fixtures

Docs rot; types don’t. Generate d.ts and publish them with packages. Keep Storybook stories and fixtures typed and validated at build time. Treat *.schema.ts (if you use zod/valibot) as the one source of truth and derive everything else.

Typed fixtures prevent drift between mocks and production behavior. Many outages have occured because mocks silently diverged from the transport contract.

Closing

Platforms fail at the seams, not the center. By separating domain from transport, forbidding escape hatches, versioning DTOs with intent, and testing types as contracts, you make correctness the default. The rest is perfomance and product iteration.

Here are some KPI set up to move risk on my project:

  • Percent of any/unknown in CI (must trend toward zero)
  • Type error rate per PR and time-to-fix (kept low by local strictness).
  • Domain type coverage across critical flows
  • Mapper test coverage and exhaustiveness checks

One last note: keep your type boundaries boring, explicit, and well-named. Most incidents trace back to an implicit definition or a hand-waved mapper that “worked once.”

Last updated
© 2025, Devpulsion.
Exposio 1.0.0#82a06ca | About