1 puntos por GN⁺ 4 시간 전 | 1 comentarios | Compartir por WhatsApp
  • 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, string y Email no se separan naturalmente, así que se imita un límite nominal con tipos con marca basados en unique symbol y aserciones as limitadas
  • Una unión discriminada como Parsed<T> expone el éxito y el fracaso en la firma de tipos, pero como no existe una expresión match dedicada, hay que escribir manualmente una verificación exhaustiva usando never
  • 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: string o User.age: number, aunque isValidUser(user): boolean pase, TypeScript no recuerda ese hecho
  • Después, en código como emailService.send(user.email, ...), user.email sigue 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 sendWelcome necesariamente hay que pasar por el parser, y dentro de la función no hace falta revalidar ni agregar if defensivos
  • 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 string es un string, y no hay una función que cree un tipo realmente distinto como el newtype de Haskell
  • 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 symbol que no se exporta fuera del módulo
  • Los tipos de ejemplo tienen la forma type Email = string & { readonly [EmailBrand]: true } y type Age = number & { readonly [AgeBrand]: true }
  • El campo de marca es un marcador a nivel de tipos que no existe en runtime, y hace que Email y string sean tratados de forma distinta en tiempo de compilación
  • La marca funciona en una sola dirección
    • Email puede asignarse a string
    • Un string común no puede entrar directamente como Email

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 mediante raw as Email
  • La aserción as Email es una excepción permitida porque el parser es el límite de confianza
    • Si en otro lugar del código se afirma que un string es Email, 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
  • 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 parseEmail es 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 UnvalidatedUser y ValidUser permite distinguir claramente entre valores que vienen de la red o de entradas externas y valores confiables dentro del dominio
    • UnvalidatedUser deja id, email y age como unknown
    • ValidUser usa tipos con marca como UserId, Email y Age
  • Si UserId también se marca, se puede evitar el error de pasar otro ID, como OrderId, donde se necesita un UserId
  • 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, email y age
    • Comprueba si email es un string
    • Llama a parseUserId, parseEmail y parseAge, 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 Email dentro 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 match dedicada
    • Hay que escribir manualmente un patrón como const _exhaustive: never = result en el default de un switch
    • Si se agrega una tercera variante a Parsed, la asignación a never falla y el compilador indica la ubicación
  • satisfies puede usarse como un escape hatch más educado que un cast
    • const x = { ... } satisfies Config verifica el tipo sin ampliar innecesariamente los tipos literales
  • Como JSON.parse devuelve any, es más seguro anotarlo de inmediato como unknown
    • Recibirlo con la forma const raw: unknown = JSON.parse(input) y luego dejar que el parser determine si es un tipo de dominio
    • JSON.parse no es un validador, sino una etapa de deserialización que convierte bytes en valores JS

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 User que viene de la red no es un User de 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 unknown de 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 if defensivo 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

 
GN⁺ 4 시간 전
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.

    • Hay restricciones como una base de código existente, el dominio de cierto lenguaje dentro del equipo o lineamientos de la empresa, y un menor nivel de soporte, herramientas y tamaño de comunidad.
      La mayoría simplemente no tiene la opción ni el tiempo de elegir otro lenguaje.
    • Normalmente supongo que es porque ya hay una gran base de código en TypeScript, o porque se usa una biblioteca de TypeScript que no existe en otros lenguajes.
  • 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.

    • A mí también me gustan los tipos con marca, pero cuando lo hablo con otros, la mayoría piensa que no valen tanto la pena en relación con el esfuerzo que requieren.
      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 estás dispuesto a mentir con suficiente fuerza, sí se puede crear un Array que solo se pueda indexar con números con marca.
      Si quieres, también se puede hacer lo mismo con los valores de TypedArray.
    • En el trabajo usamos “smart enums” y tipos de arreglo personalizados para poder escribir algo como TArray<Foo, MyEnum>. Eso sí, estoy hablando de C++.
      La biblioteca std de Zig tiene EnumArray implementado con comptime. 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.