Modelado de dominio para aplicaciones frontend utilizando TypeScript
Publicado: 04/04/2022 · Tiempo de lectura: 10 minutos
Este artículo fue publicado originalmente aquí
Una de las cosas más gratificantes de utilizar TypeScript en vez de JavaScript para desarrollar aplicaciones en el frontend, es la habilidad que los tipos nos dan para modelar el negocio en términos de código. De seguro, la contundencia en los tipos y sugerencias en el código - que son dos beneficios que obtenemos al utilizar lenguajes con tipos estáticos -, son de mucha utilidad para mejorar la calidad del código y la experiencia de desarrollo, correspondientemente. En mi experiencia construyendo aplicaciones tanto con JavaScript como TypeScript, en lo que he encontrado más valor al utilizar tipos estáticos, es en el modelado del dominio.
Buen modelado del dominio no solo sirve como excelente documentación: también pone al negocio más cerca de los desarrolladores. Cuando los desarrolladores nos convertimos en expertos del negocio, la complejidad incidental del software se reduce, dado que poseemos un mejor entendimiento de los problemas que queremos resolver por medio de software. Buen modelado del dominio solo es posible cuando los desarrolladores entendemos el negocio.
Estas ideas alrededor de el modelado de dominio vienen desde el domain-driven design. Este artículo de Martin Fowler es un buen punto de partida si quieres aprender más al respecto.
En cuanto a las clásicas aplicaciones de empresa, la mayoría de las veces la lógica de negocio se va a encontrar en el backend, sin embargo hay mucho valor en poder describir como las entidades de negocio interactúan en el frontend.
En mi experiencia en Cornershop by Uber, utilizar TypeScript para proyectos grandes, ha pagado excelentes dividendos en términos de mantenibilidad, que es algo que quieres garantizar en equipos grandes de desarrollo para aumentar la productividad y calidad del código.
En este artículo exploraremos alguna técnicas para hacer modelado del dominio en TypeScript. Sin mucho más preámbulo, vamos!
La aplicación de pendientes.
Utilizaremos una aplicación de pendientes (también conocida como to-do app) para demostrar las distintas técnicas a presentar en este artículo. El objetivo no es construir una aplicación en sí, sino explorar el dominio de una aplicación con dichas características e intentar modelar su dominio por medio de tipos estáticos.
Si nunca has visto una aplicación de pendientes, puedes ver una https://todomvc.com/.
En la mayoría de aplicaciones de tareas en TypeScript, probablemente definiríamos una tarea pendiente de la siguiente forma:
interface ToDo {id: stringtitle: stringcompleted: boolean}
Además de referirnos a las tareas de forma individual, también lo podemos hacer como un listado. Para esto podríamos usar un tipo literal ToDo[]
(o Array<ToDo>
) o mucho mejor, definir un alias para el tipo en sí, ya que oculta los detalles de implementación de qué tipo es utilizado bajo la capota:
type ToDos = Array<ToDo>
Acá utilizamos el tipo Array
correspondiente a un arreglo nativo en JavaScript, pero podríamos utilizar nuestra propia estructura de datos que represente a una lista, y aún así nos referiríamos a los pendientes como ToDos
en el resto del código. Por ejemplo, en una aplicación de React:
import React from 'react'import type { ToDos } from '../path/to/todos'interface ToDoProps {todos: ToDos}const ToDoList = (props: ToDoListProps) => {return <>{/* etc... */}</>}export default ToDoList
Independientemente de la semántica de qué tipo de lista estemos utilizando, tener un alias para ToDos
nos ayuda para mejorar la mantenibilidad del código, y fomenta mantener un lenguaje ubicuo. Hablaremos más al respecto en un minuto.
Definir tipos según la necesidad del negocio.
Volvamos a nuestra definición de ToDo
:
interface ToDo {id: stringtitle: stringcompleted: boolean}
Lo que podemos inferir de esta interfaz es que tenemos dos tipos de pendientes: pendientes completados y pendientes por completar. Si acaso nos referimos a ambos como conceptos diferentes o no, depende de como el negocio lo hace en la realidad.
Si nuestros expertos del negocio se refieren a pendientes completados y por completar como dos cosas concretas distintas, nuestra definición actual de ToDo
está desalineada con la realidad del negocio. Aquí es donde el concepto del lenguaje ubicuo emerge en domain-driven design. No lo voy a explicar en cabalidad, pero en resumen: todos los miembros de una organización deberíamos entender lo mismo cuando nos referimos a un concepto. Cuando un área (desarrollo, por ejemplo) tiene un entendimiento diferente de un concepto al resto de la organización, la comunicación sufre. Tener un lenguaje común para todos resuelve este problema.
Así un ToDo
es en realidad un CompletedToDo
o un UncompletedToDo
. Podemos modelar esto en TypeScript utilizando un tipo de unión (también conocido como tipo de suma):
type ToDo = CompletedToDo | UncompletedToDo
Esta definición de un ToDo
hace más sentido con nuestra definición hipotética del negocio. ¿Pero cómo lucirían estos tipos?
Hay múltiples formas de implementar estos tipos utilizando TypeScript y voy a mostrar varias formas de hacerlo. Sin embargo, algunas de estas implementaciones no son prácticas para este ejemplo en particular. Mi consejo es escoger lo que te haga más sentido dependiendo del problema que vayas a resolver.
Utilizando tipos literales.
La forma más simple de definir nuestros tipos nuevos sería definiendo cada tipo separadamente:
type ToDo = CompletedToDo | UncompletedToDotype ToDos = Array<ToDo>interface CompletedToDo {id: stringtitle: stringcompleted: true}interface UncompletedToDo {id: stringtitle: stringcompleted: false}export type { ToDo, ToDos, CompletedToDo, UncompletedToDo }
Notemos como un CompletedToDo
siempre tendrá su propiedad completed
definida como true
, y UncompletedToDo
como false
.
La desventaja de esta implementación es que nos estamos repitiendo con las propiedades en común entre ambas tareas. Podemos resolver esto al extender un tipo base en tres formas diferentes.
Utilizando extends.
La palabra reservada extends
puede ser utilizada para definir una interfaz que utiliza otra interfaz o tipo como base:
type ToDo = CompletedToDo | UncompletedToDotype ToDos = Array<ToDo>interface BaseToDo {id: stringtitle: stringcompleted: unknown}interface CompletedToDo extends BaseToDo {completed: true}interface UncompletedToDo extends BaseToDo {completed: false}export type { ToDo, ToDos, CompletedToDo, UncompletedToDo }
Definimos un tipo BaseToDo
que tiene todas las propiedades comunes para los pendientes completados y por completar, y luego la utilizamos como base para crear versiones especializadas.
En este caso el tipo
BaseToDo
debería permanecer privado, dado que solo sirve un propósito auxiliar para definir nuevos tipos.
Notemos como el tipo de BaseToDo['completed']
es unknown
. Personalmente, prefiero utilizar unknown
en este caso en vez de boolean
, principalmente porque es una forma de indicar que un tipo concreto debe ser especificado para cualquier tipo que quiera extender BaseToDo
. Si extendemos BaseToDo
y olvidamos especificar un tipo para completed
, TypeScript nos hará saber que ese tipo es unknown
. Si bien en la mayoría de los casos utilizar boolean
sería suficiente, en lo personal prefiero los beneficios que nos da utilizar unknown
.
El compilador de TypeScript es suficientemente inteligente para entender que el tipo de ToDo['completed']
corresponde a la unión de CompletedToDo
y UncompletedToDo
.
Utilizando un tipo de intersección.
Otra forma de implementar esto es por medio de un tipo de intersección (también conocido como tipo de producto):
type ToDo = CompletedToDo | UncompletedToDotype ToDos = Array<ToDo>interface BaseToDo {id: stringtitle: stringcompleted: unknown}type CompletedToDo = BaseToDo & {completed: true}type UncompletedToDo = BaseToDo & {completed: false}export type { ToDo, ToDos, CompletedToDo, UncompletedToDo }
Existe una diferencia sutil (pero grande) entre utilizar un tipo de intersección y extender una interfaz. Cuando utilizamos un tipo de intersección, intentamos buscar la intersección entre dos tipos, o dos conjuntos, ya que los tipos son solo conjuntos de todos los valores que satisfacen dicho tipo (todos los number
, todos los string
, any
, etcétera). Si algo recordamos de teoría de conjuntos, es que una intersección puede ser vacía (cuando no existe nada en común entre dos conjuntos). Consideremos la siguiente definición del tipo Nonsense
:
type Nonsense = { completed: true } & { completed: false }
TypeScript nos dice que el tipo de Nonsense
es en realidad never
.
La intersección entre true
y false
es vacía: false
no es un subconjunto de true
, ni viceversa, por lo tanto nunca existirá un objeto donde completed
pueda ser true
Y false
(aunque Schrödinger podría estar en desacuerdo).
En el caso de CompletedToDo
, corresponde a la intersección entre el conjunto de objetos donde completed
es unknown
, que es equivalente a any
(en otras palabras, a cualquier tipo) y el conjunto donde completed
es true
. Así que la intersección corresponde a todos los objetos donde completed
es realmente true
. Lo mismo aplica para UncompletedToDo
.
Al contrario, cuando extendemos una interfaz, podemos sobrescribir el tipo inicial, así que en realidad no nos importa el tipo inicial de la propiedad que estamos sobrescribiendo en el tipo base. Si bien en nuestro caso, utilizar una interfaz o un tipo de producto nos lleva al mismo resultado, es una diferencia que tenemos que tener en cuenta.
Utilizando tipos condicionales.
Por último pero no menos importante, podemos utilizar tipos condicionales para modelar lógica al definir nuestros tipos. Para este caso en particular es como matar una mosca con una bazuca, sin embargo es algo que puede sernos útil en un escenario más complejo.
Para demostrar el uso de tipos condicionales, añadiremos un nuevo requerimiento a nuestra aplicación de pendientes. Tras un lapso de tiempo - una hora, un día, lo que sea - un pendiente completado debería mantenerse completado, por lo tanto no puede ser modificado. Digamos que representaremos este estado por medio de un campo de tipo boolean
en nuestro modelo, llamado final
, que será true
cuando un pendiente completado ya no pueda ser editado.
Nuestro modelo de ToDo
podría verse así:
interface ToDo {id: stringtitle: stringcompleted: booleanfinal: boolean}
Hay una charla muy famosa por Richard Feldman, llamada "Haciendo los estados imposibles, imposibles" - que recomiendo encarecidamente - que asegura que la forma en la que hemos decidido representar nuestro modelo es defectuosa, ya que ahora existe la chance de caer en un estado imposible: no tiene sentido que un pendiente no haya sido completado y que esté en un estado final. Esto es algo que sucede comúnmente cuando modelamos estados con booleanos, se prestan para combinaciones que no tienen sentido en el mundo real.
Para nuestro requerimiento en particular, tendría más sentido modelar el estado de nuestro quehacer con un tipo de unión o un enumerable, ya que ambos describen un conjunto finito de valores posibles. Podría ser algo por el estilo:
enum Status {Uncompleted = 'UNCOMPLETED',Completed = 'COMPLETED',Final = 'FINAL',}
Y con ello definimos ToDo
como:
type ToDo {id: stringtitle: stringstatus: Status}
Si bien no es exactamente igual,
Status
podría ser definido como la unión de'UNCOMPLETED' | 'COMPLETED' | 'FINAL'
.
Para fines demostrativos, digamos que DEBEMOS utilizar booleanos porque cambiar nuestra definición de ToDo
requeriría una refactorización que no podemos abordar en este momento.
Para ser honesto, cualquiera de los métodos demostrados anteriormente cumplen con el requerimiento:
type ToDo = CompletedToDo | UncompletedToDo | FinalToDotype ToDos = Array<ToDo>interface BaseToDo {id: stringtitle: stringcompleted: unknownfinal: unknown}type CompletedToDo = BaseToDo & {completed: truefinal: false}type UncompletedToDo = BaseToDo & {completed: falsefinal: false}type FinalToDo = BaseToDo & {completed: truefinal: true}export type {ToDo,ToDos,CompletedToDo,UncompletedToDo,FinalToDo,}
Pero por alguna razón queremos utilizar Status
ya que es es un concepto que queremos introducir en el código base lo antes posible. Al utilizar tipos condicionales, básicamente podemos computar los tipos para completed
y final
basado en el tipo de Status
. Para conseguir esto, es necesario parametrizar la definición de todos nuestros tipos. Podemos lograr esto en TypeScript si utilizamos genéricos.
Queremos que nuestro ToDo
luzca así:
type ToDo =| UncompletedToDo| CompletedToDo| FinalToDo
Donde:
type UncompletedToDo = BaseToDo<Status.Uncompleted>type CompletedToDo = BaseToDo<Status.Completed>type FinalToDo = BaseToDo<Status.Final>
Básicamente BaseToDo
depende de un parámetro S
:
interface BaseToDo<S> {}
¿Recuerdas que habíamos dicho que los tipos son nada más que conjuntos? Para poder parametrizar BaseToDo
correctamente, S
tiene que ser un subconjunto de Status
. En nuestro caso particular, S
es un subconjunto impropio, pues todos los elementos de Status
se encuentran en S
. Para anotar esto, podemos utilizar extends
al definir el tipo genérico:
interface BaseToDo<S extends Status> {}
TypeScript ahora sabe que cuando utilizamos el tipo S
dentro de nuestra definición de BaseToDo
, estamos hablando de cualquier miembro de Status
. Dicho esto, ya estamos listos para utilizar tipos condicionales para computar los tipos de completed
y final
.
Nuestro BaseToDo
se vería algo así:
interface BaseToDo<S extends Status> {id: stringtitle: stringcompleted: S extends Status.Completed | Status.Final? true: falsefinal: S extends Status.Final? true: false}
Comencemos por el tipo de completed
. Aquí vemos otro uso de la palabra extends
. En este caso se utiliza para chequear que S
es un subconjunto de Status.Completed | Status.Final
, o en otras palabras, cualquier miembro de Status
menos Status.Uncompleted
. Si ese es el caso, entonces el tipo de completed
es true
. De otra forma, debe ser false
. Recuerda que estamos hablando de tipos aquí, no de valores.
Otra forma de expresar la diferencia de
Status
menosStatus.Uncompleted
sería utilizando el tipo utilitarioExclude
de la siguiente formaExclude<Status, Status.Uncompleted>
Lo mismo aplica a final
. Si S
es un miembro del tipo con un solo miembro Status.Final
(por lo tanto, si S
es Status.Final
), luego el tipo de final
debe ser true
, de otra forma es false
.
TypeScript es suficientemente inteligente para entender el tipo computado a partir del uso de BaseToDo
:
Si no te gusta tener estas expresiones en línea dentro de la definición de BaseToDo
, simplemente podemos extraerlas en más tipos:
type Completed<S extends Status> =S extends Status.Completed | Status.Final? true: falsetype Finalized<S extends Status> =S extends Status.Final? true: falseinterface BaseToDo<S extends Status> {id: stringtitle: stringcompleted: Completed<S>final: Finalized<S>}
Nuestro ejemplo final se vería así:
type ToDo =| CompletedToDo| UncompletedToDo| FinalToDotype ToDos = Array<ToDo>enum Status {Uncompleted = 'UNCOMPLETED',Completed = 'COMPLETED',Final = 'FINAL',}interface BaseToDo<S extends Status> {id: stringtitle: stringcompleted: S extends Status.Completed | Status.Final? true: falsefinal: S extends Status.Final? true: false}type CompletedToDo = BaseToDo<Status.Completed>type UncompletedToDo = BaseToDo<Status.Uncompleted>type FinalToDo = BaseToDo<Status.Final>export type {ToDo,ToDos,CompletedToDo,UncompletedToDo,FinalToDo,}
Nuevamente, para nuestro ejemplo en particular, esto es demasiado, pero es un recurso que vale tener para cuando sea necesario.
Y eso ha sido todo por hoy. Si te ha gustado el contenido, no te olvides de darle una compartida en Twitter y seguirme por ahí.
Gracias totales y hasta la próxima.