Aprovecha el sistema de tipos
(dzombak.com)- Al programar, se puede aprovechar el sistema de tipos para distinguir con claridad distintos significados de los datos
- Usar directamente tipos genéricos como cadenas o enteros hace que se pierda el contexto y puede provocar errores
- Incluso si comparten el mismo tipo base, definir nuevos tipos según su propósito permite prevenir errores mediante verificaciones en tiempo de compilación
- En la librería de Go libwx, se definen tipos que distinguen claramente las unidades de medida para evitar errores por mezclar
float64 - En el código de ejemplo, el tipo UUID se separa en
UserIDyAccountID, para que el compilador bloquee usos incorrectos - Incluso en lenguajes cuyo sistema de tipos no es tan fuerte como Go, un simple envoltorio de tipos puede prevenir bugs
Aprovechemos activamente el sistema de tipos
El punto de partida del problema: mezclar tipos simples
- En programación, es común representar muchos valores usando solo tipos básicos como
string,intoUUID - Pero cuando el proyecto crece, se vuelve frecuente cometer errores al mezclar estos tipos simples sin distinguirlos entre sí
- Ejemplo: pasar por error una cadena
userIDcomo si fuera unaccountID, o equivocarse en el orden de una función que recibe 3 argumentosint
- Ejemplo: pasar por error una cadena
La solución: definir tipos que expresen la intención
intystringson solo bloques de construcción; si se pasan tal cual por todo el sistema, se pierde el contexto con significado- Para evitarlo, hay que definir y usar tipos únicos según cada rol
- Ejemplo:
type AccountID uuid.UUID type UserID uuid.UUID func UUIDTypeMixup() { { userID := UserID(uuid.New()) DeleteUser(userID) // Sin error } { accountID := AccountID(uuid.New()) DeleteUser(accountID) // Error: no se puede usar el tipo AccountID como UserID } { accountID := uuid.New() DeleteUserUntyped(accountID) // No hay error en tiempo de compilación; es muy probable que cause problemas en tiempo de ejecución } }
- Ejemplo:
- Así, se pueden bloquear en compilación los argumentos del tipo incorrecto
Caso real de aplicación: la librería libwx
- El autor aplica esta técnica en su librería de Go libwx
- Define tipos dedicados para cada unidad de medida y también asocia los métodos de conversión de unidades a esos tipos
- Ejemplo: el método
Km.Miles()permite distinguir claramente las unidades
- Ejemplo: el método
- Abajo se muestra un ejemplo donde el compilador bloquea tanto un orden incorrecto de argumentos como una confusión de unidades:
// Declaración de temperatura en Fahrenheit temp := libwx.TempF(84) // Declaración de humedad relativa (porcentaje) humidity := libwx.RelHumidity(67) // Se pasa por error una temperatura en Fahrenheit a una función que requiere Celsius fmt.Printf("Dew point: %.1fºF\n", libwx.DewPointC(temp, humidity)) // El compilador detecta de inmediato un error de incompatibilidad de tipos // temp (tipo TempF) no se puede usar como TempC // Se pasan los argumentos en el orden incorrecto fmt.Printf("Dew point: %.1fºF\n", libwx.DewPointF(humidity, temp)) // El compilador evita el error de tipos en los argumentos - Todo esto permite prevenir errores que podrían ocurrir si simplemente se usara
float64
Conclusión: usemos activamente el sistema de tipos
- El sistema de tipos no sirve solo para validar sintaxis, sino que también es una herramienta para prevenir bugs
- Conviene definir un tipo de ID separado para cada modelo y envolver los argumentos de funciones con tipos claros en lugar de usar
floatointdirectamente - Este enfoque es muy efectivo y fácil de implementar, incluso en lenguajes cuyo sistema de tipos no es tan fuerte como Go
- En la práctica, hay muchísimos bugs causados por mezclar UUID o cadenas
- El autor subraya que resulta sorprendente que este método tan simple no se use con más frecuencia en código de producción
Código relacionado
- El ejemplo completo puede consultarse en GitHub:
https://github.com/cdzombak/libwx_types_lab
8 comentarios
Tengo entendido que, al intentar usarlo en Kotlin, puede haber un problema de rendimiento porque los tipos primitivos se envuelven en
wrapper, por lo que se almacenan en el heap y no en el stack. Claro, en la mayoría de los casos de uso la mantenibilidad tiene prioridad. Además, se pueden minimizar los problemas de rendimiento usandovalue class.El lenguaje Ada tiene un sistema de tipos excelente en este aspecto. Puedes declarar fácilmente valores de distintas clases como tipos separados, y cuando se mezclan, el compilador los detecta muy bien.
Pregunto por curiosidad. ¿También tiene ventajas diferentes a las de otros lenguajes de tipos populares? (
Kotlin,Rust,TypeScript, ...)La ventaja de Ada en general va más por el lado de que "es mejor que C". En C, una gran limitación es que se confía en el desarrollador y se permiten muchas cosas, como las conversiones implícitas de tipos. Pero parece que a la mayoría de los desarrolladores les sigue gustando más C, quizá porque ya están acostumbrados...
Puede que sea una característica particular de la base de código en la que trabajo, pero declaramos y usamos casi todo como tipos separados. Prácticamente solo usamos tipos básicos para índices de arreglos.
Entendido, gracias.
Opinión de Hacker News
Me gusta este enfoque de "hacer que los estados inválidos sean imposibles de representar", pero un problema común de este patrón es que los desarrolladores se quedan en la primera etapa de implementar tipos: todo se vuelve un tipo, no encajan bien entre sí y aparecen muchos tipos apenas modificados, lo que hace difícil seguir y entender el código. En una situación así, preferiría usar un lenguaje dinámico con tipado débil (JS) o un lenguaje dinámico con tipado fuerte (Elixir). Pero si los desarrolladores siguen empujando el flujo guiado por tipos, por ejemplo moviendo la lógica condicional a union types que se puedan hacer pattern matching, y aprovechando bien la delegación, la experiencia de desarrollo vuelve a sentirse cómoda. Por ejemplo, una función
DewPointpuede hacerse para que acepte varios tipos y aun así funcione de forma natural.Por eso me gustaría que más lenguajes soportaran de forma nativa tipos acotados por rango (bounded), por ejemplo no solo
x: u32, sino poder hacer que el sistema de tipos fuerce que x solo permita el rango[0,10). Eso eliminaría la necesidad de bound checks al indexar arreglos. Incluso para cosas comoOption, las optimizaciones peephole serían mucho más fáciles. En Rust hay algo de esto dentro de una función gracias a LLVM, pero no se mantiene al pasar variables entre funciones.Como referencia, Ruby no tiene tipado débil sino tipado fuerte. Si haces una operación como
1 + "1", te da un error comoTypeError: String can't be coerced into Integer."Quedarse en la primera etapa de implementación de tipos" es la razón del fracaso. Por ejemplo, empezar a usar un
intenvuelto en unstructcomo UUID es un buen inicio, pero si alguien puede tomar cualquierint, envolverlo y pasarlo, entonces se rompe la propiedad de unicidad que ese UUID debería tener. Al final, lo importante esCorrect by construction, es decir, garantizar la validez desde el momento de construcción. Un tipo que deba ser único, como un UUID, debería impedir su creación salvo que realmente se haya demostrado de algún modo, ya sea lanzando una excepción en una función o constructor, o mediante otro mecanismo. Esta idea no aplica solo a UUID, sino a cualquier tipo e invariante.Últimamente sigo el patrón Red-Green-Refactor, pero en vez de depender de una prueba que falle, hago el sistema de tipos más estricto para que el bug quede atrapado por el type checker. Las nuevas funcionalidades, edge cases y bugs que no se puedan inducir por tipos siguen yendo a pruebas, pero en general un red-green-refactor apoyado en el sistema de tipos es más rápido y puede bloquear por completo grandes categorías de bugs.
Con structural types se puede aliviar la mayoría de estos problemas. Si de verdad hace falta, se puede imponer con nominal types.
Como tema relacionado con excepciones y tipos, creo que vale la pena aprovechar bien las checked exceptions para manejarlas según corresponda a cada tipo. No entiendo por qué reciben tantas críticas las checked exceptions de Java. En un proyecto donde trabajé, al principio obligaron a usarlas y a todos les molestó, pero una vez que nos acostumbramos a pensar en todos los casos excepcionales del flujo del código, terminaron gustándonos. No éramos tan estrictos en las pruebas unitarias, pero el proyecto se volvió muy robusto.
Las quejas sobre las checked exceptions de Java existen porque manejar excepciones es demasiado engorroso. Quien escribe una librería no puede decidir con claridad qué checked exceptions usar, y del lado cliente terminas haciendo manejo de excepciones innecesario cada vez que llamas una función, así que es normal que acaben fastidiando. Si fuera fácil convertirlas a otro tipo de excepción o a runtime exceptions, o si bastara con declararlas a nivel de módulo o aplicación, este problema sería menor, pero hoy es muy molesto. Además, como es fácil romper firmas, terminas necesitando excepciones específicas del dominio, y Java tampoco hace cómoda la conversión entre excepciones. Las checked exceptions están bien; lo que no me gusta es la usabilidad del manejo de excepciones en Java.
Las checked exceptions fueron criticadas por su abuso. Que Java soporte tanto checked como unchecked exceptions fue una buena decisión. Pero lo deseable es usar checked exceptions solo para cosas como las excepciones "exogenous" que menciona Eric Lippert, y convertir la mayoría de las demás a unchecked. Por ejemplo, una base de datos puede perder conexión en cualquier momento, pero andar propagando
throws SQLExceptionpor toda la call stack es demasiado molesto. Basta con atraparlo arriba de todo con un catch-all y devolver un HTTP 500. Artículo relacionadoLas checked exceptions, comparadas con las unchecked, reducen la flexibilidad al cambiar el sistema. Si una función profunda en la call stack pasa a lanzar una excepción, quizá haya que cambiar no solo el handler sino también todas las funciones intermedias. La discusión sobre el coloring de funciones async va en una línea parecida: si una función puede lanzar excepciones, debes envolverla con
try/catcho declarar que quien la llama también lanza excepciones.C# tiene tipos claros pero adoptó unchecked exceptions. La pila de errores queda ordenada de forma limpia y no hay problema. Me parece más limpio que tener handlers de excepciones con pattern matching y tratamiento bespoke en cada nivel. Si existiera un sistema robusto de resultados de error con unwrapping, sería algo parecido.
En Java también está el problema de la mala usabilidad de los tipos checked. Por ejemplo, al usar la API de streams, si una función de
mapofilterlanza una checked exception, se vuelve realmente incómodo. Si llamas varios servicios y cada uno tiene su propia checked exception, terminas atrapandoExceptiono escribiendo una lista absurda de excepciones.En general estoy de acuerdo con la idea de "crear tipos específicos", pero he tenido experiencias muy pesadas en sistemas donde absolutamente todo tiene su propio tipo, especialmente cuando se mezcla código que solo mueve bytes de un lado a otro con código de cálculo del dominio.
Entiendo esa sensación. Ya tienes los datos que necesitas, pero primero debes averiguar cómo crear el tipo o la instancia, así que si no tienes una receta parece que estás peleando con la documentación. Por ejemplo, tienes un objeto
{x, y, z}, pero antes debes usar una función comocreateVector(x, y, z): Vector; y si quieres crear unFace, necesitas algo comocreateFace(vertices: Vector[]): Face, así que el proceso se alarga sin necesidad. En bibliotecas como BouncyCastle, aunque ya tengas listo el arreglo de bytes, terminas creando varios tipos y usando sus methods antes de llegar a la funcionalidad que realmente querías.En Go es bastante fácil volver un type alias a su tipo original, por ejemplo
AccountID → int. Si estructuras bien el código, puedes tener lógica de dominio usando type aliases, y del lado de librerías que no se preocupan por el dominio, procesar todo convirtiendo a tipos más altos o más bajos, en un estilo de clean architecture. Eso sí, hace falta muchísimo código de conversión.Los phantom types son útiles en este tipo de casos. Agregas un parámetro de tipo, es decir, un genérico, pero en realidad ese parámetro no se usa en ninguna parte. Hace tiempo, escribiendo código de criptografía en Scala, todos los arreglos eran bytes, pero usábamos phantom types para evitar mezclarlos. Caso relacionado
Idealmente, el compilador solo debería verificar los tipos y luego bajar toda la lógica restante del dominio a simples copias de bytes, aunque no sé si entendí bien tu intención.
Creo que al sistema de tipos también se le aplica la regla 80/20. Si se lleva demasiado lejos, usar la librería se vuelve pesado y el beneficio real es mínimo. UUID o
Stringya son familiares, pero cosas comoAccountIDoUserIDno lo son, así que hay un costo de aprendizaje. Un sistema de tipos elaborado puede valer la pena o no, especialmente si ya tienes suficientes pruebas. Referencia relacionadaDe todos modos, para usar software tienes que saber qué son
AccountoUser, así que no me parece que una función comogetAccountByIdque recibe unAccountIdsea más difícil de entender que una función que recibe un UUID.En realidad,
Stringno es más que un conjunto de bytes y no tiene significado por sí mismo. En cambio, si vesAccountID, normalmente entiendes que es “el ID de una cuenta”. Si de verdad te interesa su representación interna, puedes revisar la definición del tipo, pero en la mayoría de los contextos basta con saber qué esAccountID. Al final, un tipo con un nombre claro genera menos confusión al usarlo. El enlace de grugbrain.dev, de hecho, me parece demasiado básico; incluso un grug brain probablemente estaría a favor de separar tipos a este nivel.foo(UUID, UUID)es mucho menos deseable quefoo(AccountId, UserId). Es autoexplicativo y, si por error inviertes el orden al llamar la función, el compilador puede detectarlo. También permite expresar estructuras de datos complejas con más claridad sin necesidad de crear tipos completamente nuevos.Respecto a la idea de que “si ya es UUID o String, entonces ya resulta familiar”, en la práctica no siempre es fácil saber exactamente cómo se guarda o convierte un UUID, ya sea GUIDv1, UUIDv4 o UUIDv7. Por experiencia, en una combinación de Java + MS SQL me tocó corregir manualmente un problema de conversión entre UUID y
uniqueidentifierpor un tema de endianess. Supongo que es un problema parecido a los enredos con conversiones automáticas de timezone en bases de datos.En realidad, tener que entender estos tipos era algo necesario de todos modos; si no, habrías terminado pasando datos incorrectos a la función.
Hace poco nuestro equipo también aplicó tipos en varias partes de código C++ donde se mezclaban muchos valores numéricos. Todo empezó al encontrar y corregir un bug, introducir tipos seguros y descubrir que había otros tres lugares con errores similares de uso de valores.
La librería mp-units (documentación oficial de mp-units) me recuerda un buen ejemplo enfocado en unidades físicas. Si usas tipos de unidad fuertes, puedes ganar seguridad, automatizar lógica compleja de conversión de unidades y manejar distintos units con código genérico. Intenté llevar esta idea al mundo de Prolog, pero a mis colegas de alrededor no les llamó mucho la atención. Ejemplo para Prolog
Hace tiempo trabajé en un proyecto que manejaba varias magnitudes físicas, como distancia, velocidad, temperatura y presión, y todo se pasaba simplemente como
float. Así que podías poner un valor de distancia donde iba una velocidad, el compilador no se quejaba y el bug solo aparecía en runtime. Lo mismo ocurría con errores de unidades, por ejemplokm/hfrente amiles/h. Yo quería aumentar los tipos para atrapar esto en etapa de desarrollo, pero en ese momento era junior y era difícil convencer a los demás.Había descartado aplicar tipos por unidad física por miedo a que fuera demasiado complejo, pero pienso revisar mp-units. Sobre todo porque es muy común que una variable no indique claramente en qué unidad está, y eso pasa seguido con datos externos o funciones estándar.
En C# hago tipos así:
Entonces
de esta forma puedes distinguir distintos IDs enteros. También se puede extender a
IdGuidoIdString, y para crear un nuevo marker type (M) solo hace falta agregar una línea. He usado variantes similares en TypeScript y Rust.He usado un patrón parecido. Y si el ID es un
int,enumtiene la menor fricción, aunque parecía demasiado confuso como para meterlo al código real. Discusión relacionadaA este patrón se le llama "phantom type" porque los valores de
MFoooMBarno existen en runtime.También hay librerías para esto, como Vogen. Vogen significa Value Object Generator y soporta agregar tipos de value object mediante generación de código fuente. En el readme también hay enlaces a librerías parecidas.
Ya había visto este enfoque antes, pero no entendía bien para qué servía. Hoy mismo, escribiendo una función que recibía tres argumentos de tipo string, estaba pensando si forzar el parseo de tipos por adelantado o hacerlo dentro de la función, y en realidad no necesitaba el valor parseado, así que este método era exactamente la respuesta que estaba buscando. Probablemente será lo que más influya en mi estilo de programación este año.
Mi amigo Lukas resumió esta idea como "Safety Through Incompatibility". Yo apliqué este patrón por todo mi código de golang y me resultó muy útil. Evita desde el origen que se pase el ID equivocado.
Artículo relacionado 1
Artículo relacionado 2
En Swift existe la palabra clave
typealias, pero si el tipo base es el mismo, siguen pudiendo convertirse libremente entre sí, así que en la práctica no sirve mucho para este objetivo. Un wrapper struct es lo idiomático en Swift y, usando inclusoExpressibleByStringLiteral, queda razonablemente cómodo. Aun así, me gustaría que existiera una palabra clave nueva como “strong typealias” otypecopy, para poder expresar claramente: “esto es básicamente un String, pero con un significado especial, así que no debe mezclarse con otros String”.En realidad, así funcionan la mayoría de los lenguajes, por ejemplo rust/c/c++, y como en el ejemplo de Go, se agradece cuando no hace falta crear un wrapper type. En C++ además hay que tener más cuidado, porque si no marcas el constructor como
explicit, puedes terminar metiendo unintlibremente donde se esperaba unFoo.Aunque en teoría se vea elegante, aplicarlo en la práctica puede ser complicado. Por ejemplo, cómo lo mandarías a
std::cout, o cómo lo harías compatible con funciones de terceros o puntos de extensión existentes que antes recibíanString.Haskell tiene este concepto con
newtype. En lenguajes OOP, si el tipo no esfinal, puedes crear subclases fácilmente y agregar o especializar el comportamiento que quieras. Es barato y simple, sin wrappers adicionales ni boxing. Pero en Java eso se complica porqueStringesfinal, así que es difícil especializarString.Me da curiosidad cómo querrías que se comportara exactamente de forma distinta respecto a un wrapper struct.
Rust también se usa de esta manera, así que sin duda me parece algo bueno.
Si usaran un lenguaje con un buen sistema de tipos, ¿no se podrían evitar también este tipo de cosas?..
La desaparición del Orbitador Climático de Marte de la NASA en septiembre de 1999