Modelado de dominios en TypeScript: más allá de las anotaciones de tipo básicas
Aprenda hábitos prácticos de TypeScript, desde la diferencia entre unknown y any hasta las uniones discriminadas y satisfies, que le ayudarán a modelar estados válidos en lugar de simplemente etiquetar datos.
TipScript es sorprendentemente sencillo de aprender.
Primero se aprenden las interfaces.
Luego, los alias de tipo.
Después vienen las uniones, los genericos, los tipos de utilidad y, ocasionalmente, los tipos mapeados.
En poco tiempo se puede echar un vistazo a un objeto de JavaScript simple y asignarle un tipo sin dudarlo.
Pero en algún momento, trabajar con TipScript deja de tratarse de asignar tipos a las cosas.
Se convierte en diseñar tipos de forma intencionada.
Esa es una habilidad fundamentalmente diferente que hay que desarrollar.
Mire este ejemplo:
type Payment = {
status: 'SUCCESS' | 'FAILED'
transactionId?: string
error?: string
}
A primera vista parece estar bien.
Pero piense en qué estados permite técnicamente este tipo.
Permite todos estos:
{
status: 'SUCCESS'
}
{
status: 'SUCCESS',
error: 'Something went wrong'
}
{
status: 'FAILED',
transactionId: '123'
}
{
status: 'FAILED',
error: 'Something went wrong'
}
El tipo no tiene concepto de qué combinaciones realmente tienen sentido juntas.
Esto no es una limitación del propio TipScript.
Es una señal de que el dominio fue modelado mal.
Una versión mejor se ve así:
type Payment =
| {
status: 'SUCCESS'
transactionId: string
}
| {
status: 'FAILED'
error: string
}
Ahora el sistema de tipos codifica directamente la regla de negocio real.
Un pago exitoso debe incluir un ID de transacción.
Un pago fallido debe incluir un mensaje de error.
Las combinaciones que no tienen sentido se vuelven difíciles, o incluso imposibles, de crear.
Es aquí donde TypeScript comienza a ser realmente útil.
El objetivo no es dispersar anotaciones de tipo por todas partes donde sea posible.
Sino hacer que tus tipos expresen las reglas que realmente sigue tu aplicación.
A continuación se presentan varios hábitos que te ayudan en esa dirección.
1. Deja de usar any cuando en realidad quieres decir “No lo sé”
Una de las formas más rápidas de silenciar una queja de TypeScript es esta:
const response: any = await fetchData()
A veces eso es realmente lo que sucede.
Se produce un error.
Estás en medio de la implementación.
No puedes determinar inmediatamente el tipo correcto.
Por eso se utiliza any.
El compilador deja de emitir mensajes.
Pero también lo hace toda la ayuda que te proporcionaba TypeScript.
Una vez que any se cuela en tu código:
const response: any = await fetchData()
response.user.profile.name // not checked
response.foo.bar.baz // not checked
TypeScript no tiene forma de detectar ninguno de estos errores.
Utiliza unknown cuando el valor sea realmente desconocido
const response: unknown = await fetchData()
Esto te obliga a determinar realmente cuál es el valor antes de usarlo.
if (typeof response === 'string') {
console.log(response.toUpperCase())
}
Para cualquier cosa más compleja que una primitiva, valida su estructura en los límites en lugar de hacerlo dentro del código.
La distinción es importante aquí:
unknowndice "Todavía no lo sé."anydice "No quiero que TypeScript verifique esto en absoluto."
Esas son dos intenciones muy diferentes.
Cuando se manejan datos provenientes del exterior del sistema, unknown es casi siempre el punto de partida más preciso.
2. No escriba lo que TypeScript ya sabe
Escribir código con tipado estricto no significa anotar cada variable a mano.
Esta versión:
const name: string = 'Akshat'
const age: number = 30
const active: boolean = true
no es inherentemente mejor que esta otra:
const name = 'Akshat'
const age = 30
const active = true
TypeScript ya puede inferir estos tipos por sí mismo.
Anotar todo solo añade desorden visual sin aportar información real.
Las anotaciones explícitas tienen sentido cuando transmiten algo significativo.
Por ejemplo:
function calculateTotal(
items: Product[],
discount: number
): number {
// ...
}
Aquí, la firma de la función documenta efectivamente parte de un contrato.
Esa es información realmente útil.
Una verificación útil que se puede realizar es:
¿Esta anotación le dice a TypeScript algo que ya no podría averiguar por sí mismo?
Si la respuesta es no, probablemente puedas omitirla.
3. Usa as const cuando los valores también son tipos
Considera un objeto como este:
const STATUS = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
}
A veces deseas que los valores individuales permanezcan como tipos literales en lugar de convertirse a string.
Eso es exactamente lo que te ofrece as const:
const STATUS = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
} as const
A partir de ahí:
type Status = typeof STATUS[keyof typeof STATUS]
se resuelve en:
'ACTIVE' | 'INACTIVE'
Este patrón resulta útil cuando se necesitan tanto los valores en tiempo de ejecución como el tipo correspondiente en tiempo de compilación a partir de una sola definición.
Por ejemplo:
export const ALTERNATE_CODE_TYPES = {
CHARGE_CODE: 'CHARGE_CODE',
NFTP_MDG_CODE: 'NFTP_MDG_CODE',
FACT_MDG_CODE: 'FACT_MDG_CODE',
CW1_CHARGE_CODE: 'CW1_CHARGE_CODE',
} as const
export type AlternateCodeType =
typeof ALTERNATE_CODE_TYPES[keyof typeof ALTERNATE_CODE_TYPES]
Aquí, el propio objeto y el tipo derivado comparten un mismo origen.
Eso significa que se evita mantener una declaración separada como:
type AlternateCodeType =
| 'CHARGE_CODE'
| 'NFTP_MDG_CODE'
| 'FACT_MDG_CODE'
| 'CW1_CHARGE_CODE'
además de ella.
Tener una única fuente de verdad es mucho más fácil de gestionar que sincronizar dos definiciones manualmente.
4. Utilice tipos union cuando el dominio tenga un conjunto fijo de estados
Cuando un valor solo puede adoptar un número limitado de posibilidades, sus tipos deben indicarlo directamente.
En lugar de escribir:
function setStatus(status: string) {
// ...
}
prefiera:
type Status = 'pending' | 'approved' | 'rejected'
function setStatus(status: Status) {
// ...
}
Con esto en su lugar, la siguiente llamada funciona correctamente:
setStatus('approved')
pero esta es rechazada:
setStatus('something-else')
Cuanto más estricto sea un tipo, más trabajo podrá realizar el compilador por usted.
Esta ventaja va mucho más allá de la autocompletación del editor. Una unión precisa también ayuda con:
- refactorización
- documentación
- detección de errores
- diseño de API
- descubribilidad
Si su lógica de negocio realmente solo permite tres valores posibles, no represente ese campo como una cadena flexible.
5. No recurra automáticamente a enums
A veces los enums son la herramienta adecuada, pero no deberían ser su opción por defecto para cada grupo de constantes.
Si lo único que necesita es una unión en tiempo de compilación, algo como:
type Status = 'ACTIVE' | 'INACTIVE'
suele ser suficiente.
Si también necesita que esos valores existan en tiempo de ejecución, utilice:
const STATUS = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
} as const
type Status = typeof STATUS[keyof typeof STATUS]
Esto le proporciona tanto un tipo como un objeto real con el que trabajar.
El detalle clave que hay que asimilar es que los tipos de TypeScript desaparecen una vez que se ejecuta el código. Un objeto simple no lo hace.
Por lo tanto, la pregunta que hay que plantearse es:
¿Es necesario que este valor exista en tiempo de ejecución, o solo está allí para restringir cosas en tiempo de compilación?
Elija el enfoque que se ajuste a la respuesta.
6. Hacer que los estados inválidos sean imposibles de representar
Esto podría ser la idea más valiosa de toda esta discusión.
Imagínese un componente de formulario que puede encontrarse en uno de estos estados:
- loading
- ready
- submitting
- successful
- failed
Una forma típica, pero defectuosa, de modelar esto es:
type FormState = {
loading: boolean
submitting: boolean
error?: string
data?: FormData
}
Con esta estructura, nada impide que se genere accidentalmente algo como:
{
loading: true,
submitting: true,
data: {...},
error: 'Something went wrong'
}
¿Qué representa realmente esa combinación? El sistema de tipos no lo sabe, y tampoco lo sabrá el próximo desarrollador que lo lea.
Una estructura mejor vincula los campos entre sí según el estado:
type FormState =
| { status: 'loading' }
| { status: 'ready'; data: FormData }
| { status: 'submitting'; data: FormData }
| { status: 'success'; data: FormData }
| { status: 'error'; error: string }
Ahora cada rama contiene exactamente los datos que son relevantes para ella.
function render(state: FormState) {
switch (state.status) {
case 'loading':
return 'Loading...'
case 'ready':
return state.data
case 'submitting':
return 'Submitting...'
case 'success':
return state.data
case 'error':
return state.error
}
}
Ese es el beneficio que ofrecen las uniones discriminadas.
En lugar de modelar una aplicación como un conjunto disperso de booleanos independientes y campos opcionales, se modela como un conjunto fijo de estados legítimos. Eso constituye una base mucho más fiable.
7. Tenga cuidado con las propiedades opcionales
Los campos opcionales tienen sus usos, pero también son una forma sencilla de introducir incertidumbre sin darse cuenta.
Tome este ejemplo:
type User = {
id?: string
name?: string
email?: string
}
Con esta definición, cada fragmento de código que consume un User ahora debe manejar el caso en el que ninguno de estos campos esté presente.
Pero quizás la regla real en el dominio es:
Un User siempre tiene un ID, un nombre y una dirección de correo electrónico.
Si eso es cierto, modeléelo de esa manera:
type User = {
id: string
name: string
email: string
}
Las propiedades opcionales deben reflejar campos que verdaderamente a veces faltan. No están destinadas a servir como una afirmación vaga de que quien escribió el tipo no estaba seguro de lo que la API enviaría realmente.
Si la incertidumbre proviene de algún sistema externo, háznosla frente justo en ese límite. No permitas que la incertidumbre de una integración se extienda por todo el código.
8. Comprender null vs undefined
En la práctica, esta diferencia acaba siendo más importante de lo que la gente espera.
Tomemos este ejemplo:
type User = {
middleName: string | null
}
Esta redacción sugiere:
El campo está presente, pero deliberadamente no tiene valor.
Ahora compárelo con:
type User = {
middleName?: string
}
que típicamente implica:
Puede que el campo ni siquiera esté allí.
La distinción se vuelve especialmente relevante en las APIs. En una solicitud PATCH, este cuerpo:
{
middleName: null
}
puede significar:
Eliminar el nombre del medio existente.
mientras que este cuerpo:
{}
puede significar:
Dejar el nombre del medio sin cambios.
Si los tipos no pueden expresar esa diferencia, pueden surgir errores sutiles directamente en la capa de la API.
Recuerde que los tipos existen para comunicar significado, no solo para satisfacer al compilador.
9. Use satisfies en lugar de afirmar tipos ciegamente
Considere un tipo de configuración como este:
type Config = {
timeout: number
retries: number
}
Una opción es escribir:
const config = {
timeout: 5000,
retries: 3,
} as Config
Pero as es una afirmación, y usarla básicamente le dice al compilador que acepte el valor sin cuestionarlo.
Un enfoque generalmente mejor es:
const config = {
timeout: 5000,
retries: 3,
} satisfies Config
Con esta versión, TypeScript verifica realmente que el objeto coincida con Config, manteniendo al mismo tiempo el tipo más restringido inferido del propio objeto literal.
Una forma sencilla de recordar la diferencia:
as
Trate este valor como si fuera de este tipo.
satisfies
Confirme que este valor cumple con los requisitos de este tipo.
Eso es lo que hace que satisfies sea especialmente útil para objetos de configuración, mapeos estáticos y tablas de búsqueda.
10. Trate as como un límite, no como una herramienta por defecto
Hay momentos en los que realmente se necesita una aserción de tipo. Pero escribir algo como esto:
const user = response as User
en realidad no verifica nada en tiempo de ejecución.
Supongamos que una llamada a la API realmente devuelve:
{
username: 'akshat'
}
TypeScript no tiene forma de detectar esta discrepancia, porque la aserción ya le indicó que acepte el valor tal como es. Aquí no se demuestra nada al compilador; simplemente se le pide que ignorelo.
Esto se vuelve arriesgado en lugares donde los datos provienen del exterior de la base de código, como:
- Respuestas de API
- localStorage
- Parámetros de URL
- Variables de entorno
- Entrada del usuario
- Bibliotecas de terceros
Cada vez que los datos ingresan a una aplicación desde una fuente a la que TypeScript no puede acceder, vale la pena validar esos datos en lugar de convertirlos. Una verificación de esquema en tiempo de ejecución puede confirmar algo como:
"Estos datos realmente coinciden con la estructura que espera la aplicación."
Esa es una garantía mucho más sólida que simplemente escribir:
value as User
TypeScript es una herramienta de tiempo de compilación, y una muy buena. Nunca fue diseñada para validar lo que ocurre mientras un programa se ejecuta.
El objetivo real: modelar el dominio
Una vez que se comprende esta mentalidad, TypeScript deja de parecer un ejercicio de sintaxis. En lugar de preguntarse “¿cómo escribo este objeto?”, uno comienza a preguntarse “¿en qué estados puede encontrarse realmente este objeto?”. En lugar de preguntarse “¿debería ser esta propiedad opcional?”, uno empieza a preguntarse “¿es realmente opcional esta propiedad, o solo está ocultando algo que aún no se conoce?”. En lugar de preguntarse “¿se puede usar as aquí?”, uno comienza a preguntarse “¿se puede demostrar realmente que este valor tiene el tipo que se afirma?”
Ese cambio es precisamente lo importante. Escribir buen TypeScript no consiste en añadir más anotaciones de tipo, sino en hacer que los tipos que se escriben tengan un verdadero significado.
Una regla sencilla para recordar
Cada vez que diseñe un tipo, páselo por tres preguntas:
1. ¿Qué estados son realmente válidos?
Si un tipo permite representar estados inválidos, es probable que sea necesario replantear el modelo en sí.
2. ¿Qué ya sabe el compilador?
Evite anotar cosas únicamente por costumbre: deje que la inferencia haga el trabajo para el cual ya está capacitada.
3. ¿Dónde se vuelve fiable esta información?
Cuanto más viaja la información desde su fuente externa original, mayor debe ser la certeza que sus tipos pueden expresar.
Un TypeScript sólido no se define por lo elaborados que sean sus tipos, sino por aquellos que hacen que la implementación correcta sea obvia y que la incorrecta resulte complicada de escribir.
Una vez que los tipos se diseñan con esta mentalidad, TypeScript deja de parecerse a una capa añadida sobre JavaScript; se convierte en parte de la forma en que realmente se construye una aplicación.
Lecturas relacionadas
- Errores comunes de JavaScript y TypeScript que roban el código en silencio — Explica problemas sutiles de JavaScript y TypeScript, desde comparaciones con NaN hasta cuestiones de temporización asíncrona y coerción de tipos, que causan errores a pesar de parecer correctos.
- Sustituyendo
anyde TypeScript: seis patterns seguros de tipo para casos comunes — Aprenda alternativas prácticas y seguras de tipo alanyde TypeScript, incluyendo tipos unknown, genéricos, uniones discriminadas y verificaciones exhaustivas, para manejar datos impredecibles.