Ir al contenido

Stack · TypeScript strict

TypeScript strict es una disciplina

La familia strict de flags activa, Zod en cada boundary, tipos generados desde la base de datos y la API, sin any y sin casts as en el código fuente. Código que atrapa la columna renombrada en build, antes de que lo haga un ticket de soporte.

Por qué este stack

Strict es una familia de flags

`strict: true` es el suelo, no el techo. `noUncheckedIndexedAccess` hace que el acceso a un array devuelva `T | undefined` (la verdad). `exactOptionalPropertyTypes` deja de tratar `undefined` y "ausente" como lo mismo. `noImplicitOverride` hace honesta la herencia. Enviamos cada proyecto con la familia entera activa.

Zod en cada boundary, validado en runtime

Una type assertion es una mentira. `as User` no comprueba que el valor sea un `User`; le dice al compilador que confíe. Usamos schemas Zod en cada boundary de sistema (submit de form, respuesta API, lectura DB, variables de entorno) e inferimos el tipo TypeScript desde el schema. Check en runtime y tipo compile-time tienen la misma forma.

Los tipos generados sustituyen las interfaces escritas a mano

La base de datos tiene tipos, la spec OpenAPI tiene tipos, el frontmatter MDX tiene tipos. Generamos el TypeScript desde la fuente de verdad y committeamos el archivo generado. La CI falla si el archivo va en drift. Una sola fuente de verdad por sistema, nunca dos.

Sin any, sin as, sin non-null assertion

Tres vías de escape que el compilador ofrece; tres vías de escape que desactivamos en ESLint. `any` pasa a ser `unknown` más un paso de type narrowing. `as` pasa a ser un parse Zod. `!` pasa a ser un null check de verdad. El codebase dice lo que significa.

Los type checks corren en CI

Un pre-commit hook ejecuta `tsc --noEmit`. La CI lo ejecuta en cada PR. La build falla en errores de tipo antes del deploy. Un error de tipo es un P0 en el contrato; no lo merge-amos con un comentario TODO.

Qué construimos con esta tecnología

tsconfig con la familia strict completa

`strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `noImplicitReturns`, `noUnusedLocals`, `noUnusedParameters`.

Schemas Zod en cada boundary

Input de formularios, respuesta API, lectura DB, variables de entorno, frontmatter MDX, webhooks de terceros. El schema es la fuente de verdad; el tipo TypeScript se infiere.

Tipos de base de datos generados

`supabase gen types typescript --linked` corre en cada migration, la salida queda committeada, la CI falla en drift.

Tipos API generados

Specs OpenAPI importadas con `openapi-typescript` para APIs de terceros que las publican. Schemas manuales para las que no, con changelog versionado.

Patrones React en modo strict

Tipos de input y output de Server Actions, tipos de props de Client Components, tipos de estado de error boundaries: todos derivados de schemas Zod.

Discriminated unions para state machines

Estados de loading / success / error modelados como discriminated unions, con checks `never` para exhaustividad.

Routing type-safe

Params de rutas Next.js tipados vía literal de ruta, union de locales, union de slugs, query string parseado por Zod.

Migración desde TypeScript loose

Codebase actualizado a la familia strict de forma incremental, una regla cada vez, con errores de tipo arreglados en sus propios commits para que la review se mantenga sana.

Config ESLint para patrones strict

`no-explicit-any`, `no-non-null-assertion`, `consistent-type-imports`, `consistent-type-definitions`, `no-misused-promises`, `restrict-template-expressions`.

Type-check en pre-commit

Un hook husky ejecuta `tsc --noEmit` antes de cada commit. Los errores de tipo bloquean el commit, no un comentario en code review.

Type-check en CI en cada PR

GitHub Actions ejecuta `tsc --noEmit && eslint .` en cada PR. El gate del status de build bloquea el merge en rojo.

Documentación generada desde los tipos

Referencias de API, tablas de props, formas de frontmatter: extraídas del propio TypeScript con TypeDoc o un generador custom.

Una discriminated union, Zod en el boundary, sin any en el código

Un fetcher de facturas que lee un payload desconocido, lo valida con Zod, lo estrecha en una discriminated union y devuelve un resultado tipado sobre el que quien llama puede hacer switch. Sin any, sin as, sin non-null assertion.

La mayoría de los consejos sobre "TypeScript strict" se queda en activar strict: true en tsconfig.json y dar el día por bueno. El modo strict en producción se parece al fetcher de facturas de abajo: la respuesta de red llega como unknown, Zod la valida, el resultado se estrecha en una discriminated union, y quien llama hace switch exhaustivo sobre la union. Sin any, sin as User, sin data!.invoice non-null assertion.

1. El schema es la fuente de verdad

Un schema Zod define la forma. El tipo TypeScript se infiere desde él; el check en runtime usa la misma definición. Añadir un campo significa editar un solo sitio.

// src/lib/billing/schemas.ts
import { z } from 'zod'

export const InvoiceStatus = z.enum([
  'draft',
  'open',
  'paid',
  'void',
  'uncollectible',
])

export const Invoice = z.object({
  id: z.string().uuid(),
  number: z.string(),
  status: InvoiceStatus,
  amountCents: z.number().int().nonnegative(),
  currency: z.string().length(3),
  customerEmail: z.string().email(),
  createdAt: z.string().datetime(),
})

export type Invoice = z.infer<typeof Invoice>

2. El fetcher devuelve una discriminated union

El tipo de retorno es una union de Success | NotFound | InvalidResponse. Quien llama no puede tratar accidentalmente el caso de fallo como success porque TypeScript estrecha por tag.

// src/lib/billing/fetcher.ts
import { z } from 'zod'
import { Invoice } from './schemas'

const NotFound = z.object({
  status: z.literal(404),
})

export type FetchInvoiceResult =
  | { tag: 'success'; invoice: Invoice }
  | { tag: 'not_found' }
  | { tag: 'invalid_response'; reason: string }

export async function fetchInvoice(id: string): Promise<FetchInvoiceResult> {
  const response = await fetch(`/api/invoices/${id}`)

  if (response.status === 404) {
    return { tag: 'not_found' }
  }

  const raw: unknown = await response.json()

  const parsed = Invoice.safeParse(raw)
  if (!parsed.success) {
    return {
      tag: 'invalid_response',
      reason: parsed.error.message,
    }
  }

  return { tag: 'success', invoice: parsed.data }
}

3. Quien llama hace switch exhaustivo

Quien llama gestiona cada caso de la union. La assertion never al final es un check compile-time: si alguien añade un nuevo tag a la union y se olvida de manejarlo aquí, la build falla.

// app/[lang]/invoices/[id]/page.tsx
import { notFound } from 'next/navigation'
import { fetchInvoice } from '@/lib/billing/fetcher'
import { InvoiceView } from './InvoiceView'

interface Props {
  params: Promise<{ id: string }>
}

export default async function InvoicePage({ params }: Props) {
  const { id } = await params
  const result = await fetchInvoice(id)

  switch (result.tag) {
    case 'success':
      return <InvoiceView invoice={result.invoice} />
    case 'not_found':
      notFound()
    case 'invalid_response':
      return <InvalidResponseError reason={result.reason} />
    default:
      return assertNever(result)
  }
}

function InvalidResponseError({ reason }: { reason: string }) {
  return (
    <section className="ds-section">
      <h1>This invoice could not be displayed.</h1>
      <p>The server returned a payload we did not recognise.</p>
      <details>
        <summary>Technical detail</summary>
        <pre>{reason}</pre>
      </details>
    </section>
  )
}

function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`)
}

4. La config ESLint que sostiene la línea

La config de lint desactiva las tres vías de escape hacia las que el codebase deriva por defecto. La CI ejecuta tanto tsc --noEmit como eslint .; el fallo de uno cualquiera bloquea el merge.

// eslint.config.js (extracto)
export default [
  {
    rules: {
      '@typescript-eslint/no-explicit-any': 'error',
      '@typescript-eslint/no-non-null-assertion': 'error',
      '@typescript-eslint/consistent-type-imports': [
        'error',
        { prefer: 'type-imports' },
      ],
      '@typescript-eslint/no-misused-promises': 'error',
      '@typescript-eslint/restrict-template-expressions': [
        'error',
        { allowNumber: true, allowBoolean: false, allowNullish: false },
      ],
    },
  },
]

5. Qué te compra esto

Una columna de la base de datos renombrada rompe la build, no un ticket de customer support. Un nuevo tipo de evento desde un webhook de terceros es un error de TypeScript, no un fall-through silencioso. Un cambio al schema del formulario fluye automáticamente al tipo de Server Action, al tipo de la base de datos y al tipo de las props de UI. El compilador pasa de molestia a colega que revisa cada cambio antes de que lo vea el equipo.

TypeScript strict no es sintaxis más severa; son tipos honestos. El codebase pasa a ser la documentación, y la documentación nunca se queda obsoleta porque la build fallaría si lo hiciera.

Preguntas frecuentes

Cuéntanos sobre tu codebase TypeScript

Builds greenfield strict y migraciones strict igual de bienvenidos.