No valides: parsea — en lenguajes donde no querrías, como TypeScript
(cekrem.github.io)- Cuando verificaciones como
if (user.email)quedan dispersas en el código TypeScript, el hecho ya comprobado no queda reflejado en el tipo, por lo que más adelante en la pila de llamadas se sigue dudando de la misma condición - Un parser toma una entrada cruda y devuelve un tipo más estrecho o información de falla, permitiendo que el resto del programa confíe en hechos validados, como
EmailAddress - En TypeScript, que usa un sistema de tipos estructural,
stringyEmailno se separan naturalmente, así que se imita un límite nominal con tipos con marca basados enunique symboly asercionesaslimitadas - Una unión discriminada como
Parsed<T>expone el éxito y el fracaso en la firma de tipos, pero como no existe una expresiónmatchdedicada, hay que escribir manualmente una verificación exhaustiva usandonever - Zod, io-ts y valibot permiten crear parsers y tipos TypeScript a partir de un esquema, pero la disciplina de parsear en cada límite antes de tratar entradas externas como tipos de dominio sigue quedando en manos del desarrollador
La validación descarta información; el parsing la deja en el tipo
- El principio Parse, don’t validate, de Alexis King pone en el centro la diferencia entre un validador y un parser
- Un validador decide “este valor está bien” y luego pasa el flujo mediante un booleano o una excepción
- Un parser toma una entrada cruda y crea un tipo más preciso, o devuelve el motivo de la falla
- Si el tipo sigue siendo amplio, como
User.email: stringoUser.age: number, aunqueisValidUser(user): booleanpase, TypeScript no recuerda ese hecho - Después, en código como
emailService.send(user.email, ...),user.emailsigue siendo un string genérico, que podría ser una cadena vacía,"hello"o"definitely not an email" - El flujo que vuelve a comprobar la misma condición en varios lugares se parece a lo que King llama shotgun parsing
APIs donde el propio tipo es la prueba
- La forma deseada es una firma de función que solo acepte valores parseados, como
sendWelcome(user: ValidUser) - Con esta estructura, antes de llamar a
sendWelcomenecesariamente hay que pasar por el parser, y dentro de la función no hace falta revalidar ni agregarifdefensivos - En Elm esto puede resolverse de forma simple con opaque types y smart constructors, pero en TypeScript se necesitan más mecanismos para lograr el mismo efecto
Crear límites nominales con tipos con marca
- TypeScript usa un sistema de tipos estructural, por lo que los tipos con la misma shape se tratan como el mismo tipo
- Un
stringes unstring, y no hay una función que cree un tipo realmente distinto como elnewtypede Haskell
- Un
- La alternativa que usa la comunidad es el branding o etiquetado
- Una forma simple es un campo fantasma de literal de string como
{ readonly __brand: "Email" } - Una forma más fuerte es usar como clave de la marca un
unique symbolque no se exporta fuera del módulo
- Una forma simple es un campo fantasma de literal de string como
- Los tipos de ejemplo tienen la forma
type Email = string & { readonly [EmailBrand]: true }ytype Age = number & { readonly [AgeBrand]: true } - El campo de marca es un marcador a nivel de tipos que no existe en runtime, y hace que
Emailystringsean tratados de forma distinta en tiempo de compilación - La marca funciona en una sola dirección
Emailpuede asignarse astring- Un
stringcomún no puede entrar directamente comoEmail
El parser solo permite aserciones en el límite de confianza
parseEmail(raw: string): Parsed<Email>devuelve una falla si el string no contiene@; si pasa, crea el tipo con marca medianteraw as Email- La aserción
as Emailes una excepción permitida porque el parser es el límite de confianza- Si en otro lugar del código se afirma que un
stringesEmail, el diseño se rompe - Se puede poner el parser en un módulo separado y tratar como bug cualquier aserción de marca que aparezca fuera de él
- Si en otro lugar del código se afirma que un
- En el ejemplo,
Parsed<T>tiene la forma{ kind: "ok"; value: T } | { kind: "err"; error: ParseError }- La falla no queda oculta como excepción, sino que aparece en la firma de tipos
- Si se usa un discriminador de string como
kind: "ok" | "err", el narrowing de tipos funciona de manera más honesta cuando se agregan variantes después
- El ejemplo de
parseEmailes intencionalmente delgado; un parser de email real debería manejar más cosas, como trim, lowercase y validación de dominio
Separar la entrada cruda de los tipos de dominio confiables
- Separar
UnvalidatedUseryValidUserpermite distinguir claramente entre valores que vienen de la red o de entradas externas y valores confiables dentro del dominioUnvalidatedUserdejaid,emailyagecomounknownValidUserusa tipos con marca comoUserId,EmailyAge
- Si
UserIdtambién se marca, se puede evitar el error de pasar otro ID, comoOrderId, donde se necesita unUserId parseUser(raw: unknown): Parsed<ValidUser>estrecha la entrada cruda paso a paso- Comprueba si la entrada es un objeto
- Comprueba si existen los campos
id,emailyage - Comprueba si
emailes un string - Llama a
parseUserId,parseEmailyparseAge, y devuelve de inmediato si alguno falla - Si todo tiene éxito, devuelve
ValidUser
- Este enfoque es más verboso que en F# o Elm, pero hace que
sendWelcome(user: ValidUser)sea realmente seguro
Los puntos donde TypeScript incomoda
- La primera fricción es la aserción
as Emaildentro del parser- En un lenguaje con tipos nominales reales, un smart constructor puede devolver un tipo nuevo sin mentir
- Las marcas de TypeScript son marcadores de tipo ficticios, por lo que el parser debe pasar por una aserción
- La segunda fricción es la verificación exhaustiva
- Las uniones discriminadas de TypeScript son potentes en este estilo, pero no existe una expresión
matchdedicada - Hay que escribir manualmente un patrón como
const _exhaustive: never = resulten eldefaultde unswitch - Si se agrega una tercera variante a
Parsed, la asignación aneverfalla y el compilador indica la ubicación
- Las uniones discriminadas de TypeScript son potentes en este estilo, pero no existe una expresión
satisfiespuede usarse como un escape hatch más educado que un castconst x = { ... } satisfies Configverifica el tipo sin ampliar innecesariamente los tipos literales
- Como
JSON.parsedevuelveany, es más seguro anotarlo de inmediato comounknown- Recibirlo con la forma
const raw: unknown = JSON.parse(input)y luego dejar que el parser determine si es un tipo de dominio JSON.parseno es un validador, sino una etapa de deserialización que convierte bytes en valores JS
- Recibirlo con la forma
Librerías como Zod reducen la repetición
- Zod, io-ts y valibot ofrecen el mismo patrón de una forma más cómoda que un parser escrito a mano
- El ejemplo con Zod crea el parser y el tipo TypeScript a partir de un único esquema
z.object({ id: z.number().int(), email: z.string().email().brand<"Email">(), age: z.number().int().min(0).max(150).brand<"Age">() })- Se obtiene el tipo con
z.infer<typeof ValidUserSchema> ValidUserSchema.safeParse(rawInput)devuelve data si tiene éxito, o error si falla
- El
.brand()de Zod también es una función a nivel de tipos, como una marca con symbol hecha a mano, y no tiene comportamiento en runtime - La librería facilita mantener los límites al unir parser y tipo en una misma definición, pero no impone por sí misma la disciplina de usarla en todos los límites externos
- Un
Userque viene de la red no es unUserde dominio hasta que se parsea, y hay que evitar la tentación de esquivar los mensajes de error con aserciones de tipo
Llevar la evidencia en el tipo, no en la memoria
- El principio pequeño es: “haz que el sistema de tipos cargue con la evidencia; no la dejes a la memoria humana”
- Si se comprueba una condición y el resultado no se codifica en el tipo, el código posterior asumirá fácilmente que esa validación ya terminó
- En TypeScript, este principio se implementa apoyándose en tres herramientas
- Tipos con marca que imitan identidad nominal
- Uniones discriminadas que exponen éxito y fracaso
- Un límite estricto entre el
unknownde las entradas externas y los tipos de dominio confiables
- No siempre corresponde convertir todo el código en un pipeline de parsing, pero si el mismo
ifdefensivo se repite en varios archivos, es una señal de que la información que debía validarse no quedó expresada en los tipos
1 comentarios
Opiniones en Lobste.rs
Si JavaScript/TypeScript choca, tanto técnica como ergonómicamente, con el estilo de código que uno quiere, me pregunto si no bastaría con usar alguno de los muchos lenguajes que compilan a JS.
Se mencionan Haskell, Elm y F#, y también hay muchos lenguajes de la línea que el autor parece preferir más, como PureScript, js_of_ocaml, Reason, LunarML, etc. El autor incluso escribió un artículo llamado Why TypeScript Won’t Save You, donde lo compara más con sus lenguajes preferidos, y también mantiene https://learnelm.dev.
O quizá la comparación en sí sea el objetivo: mostrar que TypeScript no es suficiente en muchos casos e incentivar la adopción de otras toolchains o ideas.
La mayoría simplemente no tiene la opción ni el tiempo de elegir otro lenguaje.
En el trabajo me gustan mucho los tipos con marca (branded types), pero me molesta muchísimo que no se pueda crear un Array o TypedArray que solo se pueda indexar con números con marca.
TypedArray ni siquiera permite almacenar números con marca o, más precisamente, ni siquiera leerlos. Aunque hiciera falta un conjunto separado de tipos como IndexArray o IndexTypedArray, realmente quisiera que existiera esta funcionalidad.
Si usas tipos con marca para todos los ID en un esquema de base de datos bastante complejo, TypeScript te detecta cuando armas joins o condiciones sin sentido. Las firmas de las funciones también se vuelven más claras y es más difícil cometer varios errores.
Si quieres, también se puede hacer lo mismo con los valores de TypedArray.
TArray<Foo, MyEnum>. Eso sí, estoy hablando de C++.La biblioteca
stdde Zig tiene EnumArray implementado concomptime. También ofrece funcionalidades más amplias, como usar enums densos o dispersos para indexar y calcular el indexador correcto en tiempo de compilación.Cada vez me gusta más este tipado preciso. Evita en gran medida que entren bugs lógicos a la base de código.