Inicio / Artículos / IDs con marca en TypeScript: ¿Qué codificaciones realmente impiden una eliminación incorrecta?

IDs con marca en TypeScript: ¿Qué codificaciones realmente impiden una eliminación incorrecta?

Seis formas de escribir UserId e InvoiceId comparadas en una sola prueba: cuáles hacen que tsc rechace deleteInvoice(userId), y dónde Zod aporta seguridad en tiempo de ejecución.

2472 palabras

Imagínese una función auxiliar llamada deleteInvoice cuyo primer parámetro debería ser un ID de factura, y un lugar donde se llama a esta función que en su lugar pasa un ID de usuario. Si tsc finaliza con código de salida 0, cualquier tipo de ID que haya declarado no es más que documentación, y la documentación nunca ha evitado una consulta destructiva. Esta guía realiza un experimento sencillo con seis formas populares de codificar UserId y InvoiceId, muestra cuáles de ellas hacen que el compilador rechace la llamada incorrecta, y termina con una política práctica sobre dónde marcar los identificadores, dónde validarlos en tiempo de ejecución y cómo detectar los castings que silenciosamente anulan todo ello.

Por qué dos alias de cadena son del mismo tipo

El sistema de tipos de TypeScript es estructural. Dos tipos de objeto con la misma forma son intercambiables, y dos alias de string en realidad no son dos tipos distintos: se trata del mismo string bajo nombres diferentes. El comprobador no tiene nada con qué distinguirlos.

Las marcas resuelven este problema al añadir una propiedad fantasma al tipo. Dicha propiedad nunca existe en tiempo de ejecución; solo existe para que el comprobador considere UserId y InvoiceId como formas diferentes. Las marcas por intersección, las marcas con unique symbol, los prefijos de literales de plantilla y .brand() de Zod son todas variaciones de ese mismo truco.

Se incluyen en la comparación dos operadores que a menudo se confunden con marcas precisamente debido a esa confusión: satisfies y as const. Ninguno de ellos crea un tipo distinto.

Tenga presente un hecho en todo momento: una vez que se elimina TypeScript, toda codificación posterior es una cadena simple. Node no tiene ni idea de que existieran estos tipos. La única protección que se obtiene es la que impone el verificador en tiempo de compilación, además de cualquier validación en tiempo de ejecución que se agregue explícitamente.

La prueba: una llamada ilegal

Toda codificación se enfrenta al mismo punto de llamada. Una función espera un InvoiceId, pero llega un valor de tipo UserId desde otro lugar, y la pregunta es si el compilador se opone.

declare function deleteInvoice(id: InvoiceId): Promise<void>;
const userId = getUserId(); // UserId
await deleteInvoice(userId);

1. Aliases de tipo simples

Así es como comienzan la mayoría de los repositorios de código.

type UserId = string;
type InvoiceId = string;

Se compila, y la fila incorrecta desaparece. Dado que ambos nombres se resuelven en string, el verificador no tiene motivos para quejarse. Este es el fallo básico que las demás opciones intentan corregir.

2. Tipos literales con as const

Aquí, el id de usuario es un literal y el id de la factura es un tipo de literal de plantilla con un prefijo obligatorio.

const userId = "usr_123" as const;
type InvoiceId = `inv_${string}`;

Esto solo funciona en un caso muy específico. Si userId realmente tiene el tipo literal "usr_123", no se puede asignar a `inv_${string}` y la llamada es rechazada. Pero los ids reales provienen de funciones, solicitudes y bases de datos, y un getter como getUserId() suele devolver string. En ese momento, se vuelve a la opción 1. Agregar as const a algo ya tipado como string no lo convierte en nada útil. Tenga también en cuenta que lo que realmente hace el trabajo aquí es el tipo de literal de plantilla, no as const.

3. satisface string

Este patrón aparece en las revisiones de código presentado como medida de seguridad.

const userId = getUserId() satisfies string;

Se compila. satisfies verifica que una expresión cumpla con un tipo manteniendo al mismo tiempo el tipo inferido por la propia expresión; nunca introduce un nuevo tipo nominal. Es un operador útil, más parecido a una herramienta de corrección ortográfica que a un marcador de tipo, y no ofrece protección contra el envío de un identificador incorrecto.

4. Marcador de intersección

Al intersecar string con un objeto que contiene un campo __brand de solo lectura, cada identificador adquiere una forma distinta.

type UserId = string & { readonly __brand: "UserId" };
type InvoiceId = string & { readonly __brand: "InvoiceId" };

Ahora tsc rechaza deleteInvoice(userId). Esta es la versión que funciona sin ninguna biblioteca. La desventaja es que las cadenas literales ya no son válidas, por lo que cada tipo marcado necesita un constructor que convierta una cadena validada en el correspondiente marcador de tipo:

function asUserId(raw: string): UserId {
  if (!raw.startsWith("usr_")) throw new Error("not a user id");
  return raw as UserId;
}

Ese as dentro del constructor es el problema inevitable. Si el constructor es público y no realiza ninguna verificación, se convierte en una herramienta para asignar etiquetas falsas. Validar un prefijo es una verificación razonable cuando los identificadores realmente tienen prefijos. Si sus identificadores son UUID sin prefijo, no cambie el formato de almacenamiento solo para poder realizar esta verificación; valide lo que sea verdadero en realidad sobre el valor, como su formato UUID o el hecho de que acaba de ser leído de la tabla de facturas.

5. Símbolo único de marca

En lugar de una propiedad con nombre de cadena, la clave de la marca es un símbolo único declarado una sola vez.

declare const invoiceBrand: unique symbol;
type InvoiceId = string & { [invoiceBrand]: true };

El compilador rechaza la llamada incorrecta tal como ocurre en la opción 4. Dado que el símbolo está declarado en un módulo, resulta un poco más difícil para otro archivo falsificar la marca al escribir un tipo de objeto con la misma clave. El compromiso radica en la legibilidad: este patrón requiere más explicaciones en una solicitud de integración que la versión con __brand.

6. Marca de Zod

Zod puede asociar una marca al tipo que infiere y, a diferencia de todas las opciones anteriores, también puede verificar el valor en tiempo de ejecución.

const InvoiceId = z.string().startsWith("inv_").brand<"InvoiceId">();
type InvoiceId = z.infer<typeof InvoiceId>;

La llamada con un UserId de marca Zod falla en la verificación de tipos, y el análisis de usr_123 mediante el esquema de facturas falla en tiempo de ejecución porque la regla startsWith("inv_") lo rechaza. Esa verificación en tiempo de ejecución es algo que las codificaciones puramente estáticas no pueden ofrecer: un valor proveniente de una fuente no confiable, incluso uno ya mal etiquetado, es detectado cuando pasa por parse. Un cambio manual a as InvoiceId en otro lugar sigue evitando por completo Zod, por lo que la protección solo se aplica a los valores que realmente pasan por el esquema.

Scorecard

  • Alias simples: se compilan, pero las eliminaciones incorrectas pasan sin problemas.
  • as const: se compila tan pronto como el valor de origen tiene tipo string.
  • satisfies string: se compila.
  • Marca de intersección: rechazada por tsc.
  • unique symbol brand: rechazado por tsc.
  • Zod brand: rechazado por tsc, y un ID de usuario en formato bruto también es rechazado en tiempo de ejecución por parse.
  • Reproducir la comparación en tu propio proyecto

    Pon las seis codificaciones en un archivo como src/ids.ts, agrega la llamada ilegal deleteInvoice(userId) para cada una y ejecuta el compilador sin que genere salida:

    pnpm exec tsc --noEmit
    

    Luego toma un ID de usuario y pásalo por el esquema Zod, tal como lo haría un valor robado o incorrecto proveniente de una solicitud:

    InvoiceId.parse(String(userId));
    

    Si esa operación de análisis tiene éxito, la marca es simplemente una etiqueta sin ningún criterio de verificación asociado.

    No pruebe el funcionamiento de los marcadores escribiendo id as InvoiceId justo al lado de su definición. Un cast siempre se compila, por lo que esa prueba no demuestra nada.

    Identificando los casts que ya le perjudican

    Los marcadores son tan fuertes como la cantidad de lugares donde se los omite. Busque casts directos:

    rg "as InvoiceId|as UserId" src app
    

    Una lista larga indica que el marcador es en su mayor parte decorativo. Corrija los constructores y el análisis de límites antes de introducir más tipos con marcador.

    Lo que el comprobador puede y no puede garantizar

    Los marcadores son ilusorios: el JavaScript generado sigue siendo un string. El comprobador solo lo protege en el lugar donde se realiza la llamada si el valor nunca pasó por as InvoiceId ni por una función que acepte un string puro y devuelva el marcador sin verificarlo.

    satisfies sigue siendo el tipo falso más común en las reseñas. Es una buena herramienta para lo que hace, pero no está diseñado para el escritura nominal.

    Los tipos literales de plantilla como `inv_${string}` se comportan de manera similar a los tipos nominales y tienen la ventaja de documentar el prefijo dentro del propio tipo. Dejan de funcionar cuando los IDs son UUID sin prefijo. Adapte el tipo a los datos, no la base de datos al tipo.

    La separación que funciona bien en la práctica es tener las marcas Zod en el límite público y las marcas de intersección dentro de la aplicación. Analice los datos una sola vez al ingresarlos, por ejemplo en un manejador de solicitudes, y deje que el tipo marcado proporcione la garantía internamente. Volver a analizar los datos en cada paso entre una ruta como /invoices y un trabajador en segundo plano solo añade costos. Para conocer una forma de centralizar ese límite, consulte cómo proteger el límite de Express con un solo middleware Zod.

    Costos del branding

    • Constructores. Cada marca de intersección necesita uno. Dos tipos de ID significan dos funciones pequeñas, no veinte.
    • Falsa confianza. Un simple as InvoiceId colocado justo después de JSON.parse anula silenciosamente la protección para todo lo que sigue en el flujo.
  • Análisis en tiempo de ejecución. Zod cumple una doble función: valida y marca los datos, y se paga por cada análisis al ingresarlos. Esto vale la pena en las interfaces públicas, pero suele ser demasiado costoso para operaciones internas una vez que los datos ya han sido verificados. En rutas de alto tráfico, es necesario medir el costo.
  • Las consecuencias. Una llamada ilegal que se compile puede eliminar una fila que no es posible restaurar. En comparación, un fallo al ejecutar tsc no cuesta nada, y ese es todo el valor del campo fantasma.
  • Un fallo realista y la solución efectiva

    Considere una herramienta de soporte interno donde tanto UserId como InvoiceId estaban declarados como type X = string. Una pantalla relacionada con un usuario incluye su ID en la URL, y una acción de eliminación en dicha pantalla lee el ID de la URL y lo pasa a deleteInvoice. Esto se compila, pero desaparece un registro de usuario en lugar de una factura.

    La solución tentadora es renombrar los parámetros para que la intención sea más clara. Pero eso no ayuda: el siguiente sitio que realice una llamada descuidada también se compilará sin problemas.

    La solución efectiva es utilizar un tipo de intersección y un constructor de validación dentro de la aplicación para marcar los datos:

    type InvoiceId = string & { readonly __brand: "InvoiceId" };
    function asInvoiceId(raw: string): InvoiceId {
      if (!raw.startsWith("inv_")) throw new Error("not an invoice id");
      return raw as InvoiceId;
    }
    

    Y, en el límite HTTP, un esquema Zod que tanto valida como marca los datos:

    const InvoiceId = z.string().startsWith("inv_").brand<"InvoiceId">();
    

    Después del cambio, el botón de eliminación en una fila de factura obtiene su id a través de asInvoiceId desde un campo que en realidad contiene el id de la factura. La pantalla del usuario puede mantener el id del usuario en su URL, ya que esa pantalla está relacionada con el usuario. El tipo correcto habría detectado al helper original; lo mismo ocurriría con una etiquetación más clara. El equipo no contaba ni con uno ni con la otra cosa.

    Una última verificación de sentido común: una búsqueda de as InvoiceId en el código fuente debería devolver casi nada, y cada resultado encontrado debería ser justificable.

    Hacer que la verificación sea repetible

    Una rutina de verificación breve y repetible evita que estos resultados se conviertan en algo puramente legendario. Comience registrando las versiones de la herramienta, ya que su comportamiento puede cambiar entre versiones principales. La configuración de referencia para esta comparación fue una pequeña aplicación para facturas con cuatro rutas en Node 24, TypeScript 7 y Next.js 16.3; verifique las versiones en su propio entorno antes de comparar resultados.

    node -v
    pnpm exec tsc -v
    pnpm exec next --version
    

    Si una versión principal difiere de lo esperado, deténgase antes de confiar en los resultados posteriores. Luego inicie la aplicación y pruebe las rutas involucradas:

    pnpm exec next dev
    

    Visite /, /invoices, /invoices/1, /settings y nuevamente /invoices con la función de registro activada en DevTools, para poder ver qué ID lleva realmente cada pantalla en su URL.

    Finalmente, ejecute el comprobador de tipos en un formato adecuado para scripts e inspeccione su estado de salida:

    pnpm exec tsc --noEmit --pretty false
    echo $?
    

    Un código de salida cero no demuestra que el producto esté correcto. Solo significa que la capa de compilación no encontró nada, y aún es necesario verificar el comportamiento en tiempo de ejecución. También es útil anotar una línea por intento fallido (“se probó X, pero aún se observó Y”) junto con las versiones y los resultados, para que la próxima persona no repita el mismo error.

    Modos comunes en los que las marcas fracasan

    • as InvoiceId directamente después de JSON.parse: la marca se convierte en un mero elemento decorativo.
    • satisfies string se acepta en la revisión como si fuera una marca: solo verifica la conformidad.
    • Se utiliza un prefijo de literal de plantilla en una columna UUID, y luego alguien agrega ese prefijo a los datos almacenados para que el tipo encaje. Hay que revertir eso; marque el valor parseado en lugar de modificar la base de datos para adaptarla a un tipo específico.
  • Un constructor como asInvoiceId exportado desde un archivo barrel, lo que hace que esté disponible de forma sencilla para cualquier módulo que quiera omitir la validación.
  • Lista de verificación antes de llamar a un identificador tipado

    • No está declarado como type FooId = string.
    • satisfies string no es su única condición de validación.
    • Un constructor o una función de análisis de Zod lo protege en los límites.
    • deleteInvoice(userId) genera un error en tsc.
    • Los resultados de la búsqueda de as InvoiceId forman una lista breve que se puede justificar.

    También es importante el ámbito de uso. No hay necesidad de etiquetar cada cadena de texto en el repositorio. Etiqueta los identificadores que puedan destruir o exponer datos: las rutas de eliminación, reembolso e suplantación son buenos candidatos iniciales. Si al final tienes cincuenta etiquetas, estás más decorando que protegiendo.

    Un conjunto compacto de comandos abarca las verificaciones en curso, incluyendo una búsqueda de alias de identificadores que aún son cadenas simples:

    pnpm exec tsc --noEmit
    rg "as InvoiceId" src
    rg "type \w+Id = string" src
    

    La llamada ilegal deleteInvoice(userId) debería encontrarse en un archivo de pruebas para el cual se espera que fallen las verificaciones de tipo (por ejemplo, con un comentario @ts-expect-error encima de ella), y nunca en código de producción como lib/delete.ts.

    Pruébalo en tu código

    Escribe la llamada ilegal deleteInvoice(userId) junto al helper de eliminación que realmente utilizas, en un lugar donde el compilador lo revise. Si tsc no muestra ninguna advertencia, tus identificadores son en realidad comentarios. Convierte InvoiceId en un tipo de intersección y verifica que la llamada se vuelva roja. Añade un constructor de validación o un tipo Zod en el punto de entrada HTTP y asegúrate de que un valor bruto como "usr_123" genere un error. Luego busca as InvoiceId y justifica cada aparición en la solicitud de integración o elimínala.

    Puntos clave

    • Los alias, as const y satisfies no crean tipos distintos, por lo que no pueden evitar el uso de un identificador incorrecto.
    • Los tipos de intersección y los unique symbol hacen que el compilador rechace las llamadas erróneas; los tipos Zod añaden una verificación en tiempo de ejecución para los valores que pasan por parse.
  • Cada marca tiene una vía de escape en as. Mantenga las representaciones dentro de constructores pequeños que realicen validación y audite el resto.
  • Analice y asigne la marca una sola vez en el límite, transfiera la marca estática hacia adentro y reserve el proceso de asignación de marcas para aquellos IDs cuyo uso incorrecto cause daños irreversibles.