Cada cliente de API tiene una state machine escondida dentro. Primero el handshake. Segundo, autenticar. Tercero, enviar datos. Por último, cerrar. Rompe ese orden y obtienes errores en tiempo de ejecución, servidores confundidos, o peor, corrupción silenciosa de datos.

La mayoría de los equipos codifican estas reglas con comprobaciones en tiempo de ejecución. if (!connected) throw new Error(...). Eso funciona hasta que alguien olvida la comprobación, o un refactor introduce una nueva ruta de código que la omite. Para cuando te das cuenta, el bug está en producción.

El sistema de types de TypeScript puede eliminar una clase completa de estos bugs. No con reglas de lint inteligentes, sino haciendo que los estados ilegales sean literalmente irrepresentables.

Tu cliente de API tiene una state machine oculta

Los session types son una técnica del sistema de types para codificar la secuencia válida de operaciones en un protocol. En lugar de rastrear el estado con un campo de cadena en tiempo de ejecución, lo rastreas en el type parameter. Un Channel<'idle'> solo puede llamar a connect(). Un Channel<'connected'> solo puede llamar a send() y close(). El compiler rechaza todo lo demás.

Esto no es académico. Si alguna vez has usado una transaction de base de datos, una conexión WebSocket o un flujo OAuth, has aplicado manualmente un protocol de sesión. Los session types simplemente mueven esa aplicación del tiempo de ejecución al tiempo de compilación.

Los phantom types hacen que las transiciones ilegales sean irrepresentables

Aquí tienes una state machine típica en TypeScript:

class Channel {
  private state: 'idle' | 'connected' | 'closed' = 'idle';

  connect() {
    if (this.state !== 'idle') throw new Error('Already connected');
    this.state = 'connected';
  }

  send(msg: string) {
    if (this.state !== 'connected') throw new Error('Not connected');
    // ...
  }

  close() {
    this.state = 'closed';
  }
}

Esto está bien hasta que no lo está. El método send() lanza en tiempo de ejecución si lo llamas en el momento equivocado. Las pruebas podrían detectarlo. O no. Un refactor podría introducir una nueva llamada a send() después de close() que nadie nota. El compiler no tiene opinión.

TypeScript usa structural typing. Dos clases con la misma forma son intercambiables, incluso si sus type parameters difieren. Para hacer que Channel<'idle'> y Channel<'connected'> sean incompatibles, el type parameter debe aparecer en la estructura misma.

El patrón estándar es un campo phantom privado:

class Channel<State extends string> {
  private __state!: State;
  private constructor() {}

  static create(): Channel<'idle'> {
    return new Channel();
  }

  connect(this: Channel<'idle'>): Channel<'connected'> {
    return new Channel();
  }

  send(this: Channel<'connected'>, msg: string): Channel<'connected'> {
    console.log(msg);
    return new Channel();
  }

  close(this: Channel<'connected'>): Channel<'closed'> {
    return new Channel();
  }
}

El campo __state nunca recibe un valor. Existe solo para vincular el type parameter a la estructura de la clase. Como es privado, el código externo no puede fabricar un Channel<'connected'>. Como tiene tipo State, Channel<'idle'> y Channel<'connected'> son types estructuralmente diferentes.

El parámetro this en cada método restringe qué estados pueden llamarlo. TypeScript comprueba la compatibilidad de this en el sitio de la llamada, no solo dentro del cuerpo del método.

Ahora las llamadas ilegales son errores de compilación:

const ch = Channel.create();
ch.send('hello'); // Error: 'Channel<"idle">' is not assignable to 'Channel<"connected">'

Esta es la parte que confunde a la gente. El parámetro this parece una anotación en tiempo de ejecución. No lo es. TypeScript lo usa puramente para verificar el type del receptor. Si el tipo del receptor no coincide, el compiler rechaza la llamada por completo.

Construyendo un protocol de subida de archivos type-safe

El Channel de juguete muestra el patrón. Aquí tienes algo más cercano a un protocol real: un cliente de subida de archivos con autenticación y verificación de checksum.

type UploadState = 'idle' | 'ready' | 'uploading' | 'verifying' | 'done';

class Uploader<State extends UploadState> {
  private __state!: State;
  private file: string;

  private constructor(file: string) {
    this.file = file;
  }

  static forFile(file: string): Uploader<'idle'> {
    return new Uploader(file);
  }

  authenticate(
    this: Uploader<'idle'>,
    token: string
  ): Uploader<'ready'> {
    // verify token...
    return new Uploader(this.file);
  }

  uploadChunk(
    this: Uploader<'ready' | 'uploading'>,
    data: Uint8Array
  ): Uploader<'uploading'> {
    // stream bytes...
    return new Uploader(this.file);
  }

  finalize(
    this: Uploader<'uploading'>,
    checksum: string
  ): Uploader<'verifying'> {
    // start verification...
    return new Uploader(this.file);
  }

  verify(
    this: Uploader<'verifying'>,
    serverChecksum: string
  ): Uploader<'done'> {
    // compare checksums...
    return new Uploader(this.file);
  }
}

Observa que uploadChunk acepta Uploader<'ready' | 'uploading'> como su tipo this. Los union types te permiten expresar métodos que son válidos desde múltiples estados. El método devuelve Uploader<'uploading'>, así que después de la primera llamada estás bloqueado en el estado uploading hasta que finalices.

const chunk1 = new Uint8Array([0x01, 0x02]);
const chunk2 = new Uint8Array([0x03, 0x04]);

const upload = Uploader.forFile('report.pdf')
  .authenticate('token-123')
  .uploadChunk(chunk1)
  .uploadChunk(chunk2)
  .finalize('abc123')
  .verify('abc123');

// upload is now Uploader<'done'>

Intenta llamar a authenticate() después de uploadChunk(), o uploadChunk() después de verify(), y TypeScript errora inmediatamente.

Dónde el patrón se vuelve incómodo

El patrón no es gratis. El punto de dolor más grande es que cada transición de estado construye un nuevo objeto. En nuestros ejemplos creamos un nuevo Uploader incluso cuando el estado subyacente no ha cambiado, como uploadChunk devolviendo Uploader<'uploading'> desde Uploader<'uploading'>. Para objetos complejos con mucho estado, eso es derrochador.

Puedes optimizar pasando el estado en lugar de reconstruir, pero los types se vuelven más ruidosos. Un segundo problema: los mensajes de error de TypeScript para discrepancias del parámetro this son crípticos. “The ‘this’ context of type ‘X’ is not assignable to method’s ‘this’ of type ‘Y’” es preciso, pero no inmediatamente obvio para alguien que lee el código por primera vez. Buenos nombres y comentarios ayudan, pero la experiencia del desarrollador no es perfecta.

Tercero, este patrón no se compone bien con código async. Un await en medio de una cadena rompe la interfaz fluida, y necesitas almacenar el resultado intermedio con type en una variable. Eso no es un impedimento absoluto, pero sí significa que el patrón funciona mejor para protocols síncronos o cuidadosamente estructurados de forma async.

Los tagged unions son el terreno medio pragmático

Si el patrón completo de phantom types se siente pesado, los tagged unions con sentencias switch exhaustivas son una alternativa pragmática. Sigues teniendo seguridad en tiempo de compilación, pero a nivel de valor en lugar de a nivel de type:

type UploadState =
  | { tag: 'idle'; file: string }
  | { tag: 'ready'; file: string; token: string }
  | { tag: 'uploading'; file: string; sent: number }
  | { tag: 'done'; file: string };

function authenticate(
  state: Extract<UploadState, { tag: 'idle' }>,
  token: string
): UploadState {
  return { tag: 'ready', file: state.file, token };
}

Esto es más idiomático en TypeScript y más fácil de mantener para la mayoría de los equipos. La compensación es que no puedes evitar que alguien pase la variante de estado equivocada a una función a nivel de type sin los mismos trucos de parámetro this. El compiler lo detecta dentro de la función, pero el sitio de la llamada no está restringido.

Recurre a los phantom types cuando las violaciones causan daño real

Usa phantom types cuando el protocol es lo suficientemente complejo como para que violarlo cause daño real, y cuando la superficie de la API es lo suficientemente pequeña como para que controles cada transición. Los drivers de base de datos, las implementaciones de protocols de red y los SDKs stateful son buenos candidatos.

No los uses para APIs CRUD simples o para cualquier cosa donde la complejidad de type extra cueste más que la comprobación en tiempo de ejecución ocasional.

La próxima vez que escribas if (state !== 'connected') throw new Error(...), pregúntate si esa comprobación pertenece a tu código o a tus types. Los parámetros this y los campos phantom de TypeScript te dan una forma de empujar la aplicación del protocol hacia arriba. Los bugs que atrapes no serán los dramáticos. Serán los errores silenciosos que se cuelan en la revisión de código y solo aparecen cuando un cliente encuentra un caso límite que no probaste.

Esos son exactamente los bugs que vale la pena eliminar.