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: string
title: string
completed: 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: string
title: string
completed: 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 | UncompletedToDo
type ToDos = Array<ToDo>
interface CompletedToDo {
id: string
title: string
completed: true
}
interface UncompletedToDo {
id: string
title: string
completed: 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 | UncompletedToDo
type ToDos = Array<ToDo>
interface BaseToDo {
id: string
title: string
completed: 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.

Tooltipo de vscode señalando el tipo computado de ToDo[completed] a partir de la unión de CompletedToDo y UncompletedToDo
El tipo inferido de completed es la unión de true y false, que corresponde a boolean.

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 | UncompletedToDo
type ToDos = Array<ToDo>
interface BaseToDo {
id: string
title: string
completed: 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.

Tooltip de vscode señalando que el tipo Nonsense es equivalente al tipo never
Una intersección vacía.

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: string
title: string
completed: boolean
final: 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: string
title: string
status: 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 | FinalToDo
type ToDos = Array<ToDo>
interface BaseToDo {
id: string
title: string
completed: unknown
final: unknown
}
type CompletedToDo = BaseToDo & {
completed: true
final: false
}
type UncompletedToDo = BaseToDo & {
completed: false
final: false
}
type FinalToDo = BaseToDo & {
completed: true
final: 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: string
title: string
completed: S extends Status.Completed | Status.Final
? true
: false
final: 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 menos Status.Uncompleted sería utilizando el tipo utilitario Exclude de la siguiente forma Exclude<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:

Tooltip de vscode mostrando un error al tratar de crear un objeto de tipo CompletedToDo con final igual a true
Un elemento de tipo CompletedToDo nunca podrá tener una propiedad final que sea true.

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
: false
type Finalized<S extends Status> =
S extends Status.Final
? true
: false
interface BaseToDo<S extends Status> {
id: string
title: string
completed: Completed<S>
final: Finalized<S>
}

Nuestro ejemplo final se vería así:

type ToDo =
| CompletedToDo
| UncompletedToDo
| FinalToDo
type ToDos = Array<ToDo>
enum Status {
Uncompleted = 'UNCOMPLETED',
Completed = 'COMPLETED',
Final = 'FINAL',
}
interface BaseToDo<S extends Status> {
id: string
title: string
completed: S extends Status.Completed | Status.Final
? true
: false
final: 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.