Stack · TypeScript strict

TypeScript strict es una disciplina, no un flag de config

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, no en el ticket de customer support.

Por qué este stack

01

Strict es una familia, no un solo flag

`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.

02

Zod en el boundary, no type assertions

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.

03

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.

04

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.

05

Type checks en el contrato, no en el editor de alguien

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

¿Es suficiente `strict: true`?

Es el suelo. Encendemos la familia strict entera porque los flags extra capturan bugs reales que hemos visto en producción: `noUncheckedIndexedAccess` para el array fuera de rango, `exactOptionalPropertyTypes` para la confusión optional-vs-ausente, `noImplicitOverride` para el método sombreado silenciosamente.

¿`noUncheckedIndexedAccess` no añade ruido por todas partes?

Un poco, en sitios donde de verdad hay que manejar el caso ausente. La mayoría del ruido es un bug real que se atrapa solo: código que asumía que `arr[0]` existía cuando podría no estar. Añadimos null checks explícitos (o `arr.at(0)`) y el ruido pasa a ser la documentación.

¿Cuánto dura la migración de loose a strict?

Depende del tamaño del codebase y de cuántos atajos `any` hay dentro. Un codebase mediano son uno a tres días para encender la familia strict; el trabajo mayor es sustituir `as` por Zod y `any` por `unknown` más narrowing. Migramos en commits por regla para que la review se mantenga sana.

any frente a unknown — ¿cuál es la regla?

`any` es opt-out del type checking; `unknown` es reconocer que el tipo no se conoce y demostrarlo antes de usarlo. Usamos `unknown` en cualquier sitio donde `any` sería la salida fácil, y demostramos la forma con Zod o con un narrowing de discriminated union.

¿Cuándo se permiten los casts `as`?

Casi nunca en código fuente. Permitidos en fixtures de test, en código generado desde schemas externos donde el generador emite tipos más laxos de los que queremos, y en escape hatches de framework donde el framework mismo no tiene una firma mejor. Cada caso permitido está documentado en la config de ESLint.

¿El modo strict perjudica el rendimiento de build?

No. La familia strict trata sobre detectar más problemas durante el type check; el check mismo no es más lento que el mismo check sin los flags. Las builds incrementales se quedan iguales. Las quejas de rendimiento de build casi siempre trazan a un problema distinto.

¿Cómo funciona en la práctica el workflow de tipos generados?

Un script npm regenera el archivo (base de datos, OpenAPI, frontmatter MDX). El archivo generado queda committeado. La CI vuelve a correr el mismo script y falla si la salida difiere de la copia committeada. Doble protección: nadie puede deployar tipos desfasados y nadie puede editar a mano el archivo generado.

Cuéntanos sobre tu codebase TypeScript

Una llamada de scoping, un número concreto en la primera respuesta, sin teatro de agencia. Builds greenfield strict y migraciones strict igual de bienvenidos.