Diez hábitos de TypeScript que mantienen los grandes conjuntos de código legibles y seguros
Aprenda diez hábitos prácticos de TypeScript, desde generics significativos y reducción de tipos hasta verificaciones exhaustivas, propiedades readonly y una configuración tsconfig estricta, que ayudan a mantener los conjuntos de código en buen estado.
La mayoría de los problemas con TypeScript en una base de código en crecimiento no tienen nada que ver con no saber qué son los tipos genéricos o condicionales. Proviene de decisiones cotidianas: abstracciones excesivas, tipos que aceptan demasiado, uso indiscriminado de as, contratos definidos dos veces, firmas genéricas difíciles de leer, funciones cuyos argumentos no tienen sentido en el lugar donde se llaman, tipos dispersos en archivos aleatorios, y un compilador configurado de forma demasiado laxa como para detectar lo que el equipo depende de él. En un proyecto pequeño estos hábitos apenas se notan; pero con decenas de desarrolladores y varios años, se acumulan hasta convertirse en un problema grave. Esta guía presenta diez prácticas concretas que mantienen el código en TypeScript fácil de leer y modificar, además de una lista de verificación que puedes aplicar durante las revisiones de código.
Si desea comenzar por el aspecto de modelado, incluyendo cómo hacer que los estados inválidos no sean representables, empiece con modelado de dominios en TypeScript más allá de las anotaciones básicas. Aquí el foco está en los hábitos de mantenimiento que se aplican sobre modelos sólidos.
1. Trate los genericos como una forma de expresar relaciones
Los genericos suelen presentarse como un mecanismo de reutilización, y lo son. Sin embargo, su función más importante es conectar tipos: indicar al compilador que lo que sale de una función está relacionado con lo que entró. Aquí tiene el ejemplo más sencillo y útil, una función que devuelve el primer elemento de un array.
function getFirst<T>(items: T[]): T | undefined {
return items[0]
}
El parámetro de tipo T se obtiene del argumento. Pase un array de usuarios:
const users: User[] = [...]
y el compilador deducirá el resultado en consecuencia:
const user = getFirst(users)
// User | undefined
La misma función funciona para un tipo de elemento diferente sin ninguna anotación adicional:
const products: Product[] = [...]
proporcionando un resultado del producto con el tipo correcto:
const product = getFirst(products)
// Product | undefined
Ahora observe qué sucede si se elimina el genérico y se utiliza unknown en su lugar. La función sigue ejecutándose, pero la relación entre la entrada y la salida desaparece, y cada llamante debe convertir o restringir el resultado.
function getFirst(items: unknown[]): unknown {
return items[0]
}
Una buena prueba antes de introducir un parámetro de tipo es nombrar la relación que este preserva. Si no se puede determinar qué tipo de entrada determina qué tipo de salida, es probable que el genérico no esté cumpliendo su función.
Un detalle digno de mención: con noUncheckedIndexedAccess activado (abordado en la sección 10), items[0] es tipificado como T | undefined por el propio compilador, lo cual coincide con el tipo de retorno explícito aquí.
2. Evite convertir todo en genérico
Dado que los genéricos son poderosos, es fácil abusar de ellos. Es tentador escribir una firma con varios parámetros de tipo restringidos que dependen unos de otros, como se muestra en el boceto a continuación. Parece sofisticado cuando se escribe así.
function processData<
T extends Record<string, unknown>,
K extends keyof T,
R extends ...
>(...) {
// ...
}
El desarrollador que abre ese archivo seis meses después tiende a sentirlo de manera diferente. Cada parámetro de tipo adicional es algo que el lector debe recordar. Si la función realmente solo maneja usuarios, una firma sencilla comunica mucho más:
function processUser(user: User) {
// ...
}
La competencia en TypeScript no se mide por cuánto del sistema de tipos se puede incluir en una sola declaración. Utilice genéricos cuando capturen una relación real entre tipos, y no solo porque el lenguaje lo permita.
3. Estreche los valores en lugar de convertirlos
Una aserción de tipo es la forma más rápida de silenciar una queja:
const value = something as string
El problema es que as no verifica nada. Le dice al compilador que descarte su incertidumbre y confíe en usted; si se equivoca, el error aparece en tiempo de ejecución. Un enfoque más seguro es demostrar el tipo mediante una verificación en tiempo de ejecución que el compilador pueda comprender:
if (typeof something === 'string') {
console.log(something.toUpperCase())
}
Dentro del bloque if, something es un string, ya que typeof es una construcción de reducción de tipos. Para estructuras de objetos, escriba un guardián de tipo definido por el usuario. El tipo de retorno value is User le indica al compilador que un resultado true significa que el argumento puede tratarse como un User.
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value
)
}
Los llamantes obtienen entonces la reducción de tipos de forma gratuita:
if (isUser(value)) {
console.log(value.name)
}
El contraste es sencillo: una aserción le pide al compilador que confíe en usted, mientras que los mecanismos de validación proporcionan las pruebas. Tenga en cuenta que un guardián de tipo solo es tan fiable como el contenido de su lógica. El ejemplo verifica que id y name existan, pero no qué tipos de datos contengan; por lo tanto, para datos provenientes de la red o del almacenamiento podría ser necesario realizar verificaciones más estrictas o utilizar un validador de esquema. El compilador confía plenamente en el veredicto del guardián.
4. Utilice la bandera never para ramajes incompletos
Supongamos que un estado se modela como una unión de literales de cadena:
type Status =
| 'pending'
| 'approved'
| 'rejected'
Un switch que asigna cada estado a una etiqueta parece completo:
function getLabel(status: Status) {
switch (status) {
case 'pending':
return 'Pending'
case 'approved':
return 'Approved'
case 'rejected':
return 'Rejected'
}
}
Por ahora es completo. Los problemas surgen cuando la unión crece, por ejemplo cuando alguien agrega un estado de cancelación:
type Status =
| 'pending'
| 'approved'
| 'rejected'
| 'cancelled'
Status puede utilizarse en decenas de lugares, y se desea que el compilador señale cada uno de ellos cuando ya no cubran todos los casos. La técnica estándar es utilizar una ayuda de exhaustividad que acepte el valor never. En la rama default, TypeScript ya ha eliminado todos los miembros manejados, por lo que el tipo restante debe ser never. Si algún nuevo miembro escapa, no se puede asignar a never y la compilación falla.
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${value}`)
}
function getLabel(status: Status) {
switch (status) {
case 'pending':
return 'Pending'
case 'approved':
return 'Approved'
case 'rejected':
return 'Rejected'
default:
return assertNever(status)
}
}
Después de agregar 'cancelled', la llamada a assertNever(status) se convierte en un error de compilación hasta que se maneje el nuevo caso. La definición de unión pasa a ser la única fuente de verdad, y el compilador genera la lista de lugares que deben actualizarse. Como beneficio adicional, throw te protege en tiempo de ejecución si llega un valor inesperado desde fuera del sistema de tipos.
5. Utilice readonly para indicar cómo deben utilizarse los datos
Los tipos describen qué valores están permitidos, pero también pueden indicar cómo se deben manejar esos valores. Marcar una propiedad como readonly indica que queda fija una vez que el objeto existe:
type User = {
readonly id: string
name: string
}
Una asignación como la siguiente será rechazada por el compilador:
user.id = '123'
Los arrays también pueden protegerse de la misma manera. Un parámetro de tipo readonly User[] permite a la función iterar y leer, pero no insertar elementos, modificar su estructura ni ordenarlo in situ:
function processUsers(users: readonly User[]) {
// ...
}
Esa firma le indica a todo quien llama a la función que esta no modificará su colección. readonly es especialmente útil para objetos de configuración, datos compartidos, constantes, parámetros de función y estados inmutables. La principal ventaja no radica tanto en impedir una mutación específica como en documentar la intención para todos quienes leen el tipo. Tenga en cuenta que readonly es superficial y solo aplica en tiempo de compilación: los objetos anidados siguen siendo mutables a menos que también se marquen como tales, y nada se congela en tiempo de ejecución.
6. No oculte formas reales detrás de Record<string, unknown>
Las firmas como esta son comunes:
function process(data: Record<string, unknown>) {
// ...
}
A veces ese es el tipo correcto. Si una función realmente acepta datos clave-valor arbitrarios, como un registrador genérico o una herramienta de serialización, utilizar un tipo amplio es honesto. El problema surge al usarlo cuando ya se sabe qué tipo de objeto se trata. Tomemos la misma firma:
function process(data: Record<string, unknown>) {
// ...
}
y modellemos los datos que realmente esperamos:
type User = {
id: string
name: string
}
function process(user: User) {
// ...
}
El cambio parece cosmético, pero las ventajas son significativas: autocompletado, documentación integrada, refactorización segura, garantías en tiempo de compilación y una declaración clara de intenciones. Los tipos amplios deben utilizarse en contextos verdaderamente dinámicos, como al parsear JSON desconocido, y deben convertirse en tipos reales lo antes posible después de ese contexto, en lugar de emplearse como predeterminados en todas partes.
7. Diseñe APIs de funciones que se expliquen por sí mismas
Los argumentos posicionales se vuelven poco claros rápidamente, especialmente los booleanos. Al leer una llamada de este tipo, no se puede saber qué controlan true y false sin abrir la definición:
createUser(
'Akshat',
'akshat@example.com',
true,
false,
)
Un objeto de opciones coloca el significado en el lugar donde se realiza la llamada:
createUser({
name: 'Akshat',
email: 'akshat@example.com',
sendWelcomeEmail: true,
isAdmin: false,
})
Luego, la función declara un tipo con nombre para sus opciones:
type CreateUserOptions = {
name: string
email: string
sendWelcomeEmail: boolean
isAdmin: boolean
}
function createUser(options: CreateUserOptions) {
// ...
}
La ventaja aumenta con la cantidad de parámetros. Generalmente, dos argumentos en posición son aceptables; siete casi siempre causan errores, especialmente cuando varios comparten el mismo tipo y pueden intercambiarse sin generar problemas. Un objeto de opciones también facilita la adición posterior de campos opcionales sin afectar a los llamantes existentes.
8. Mantener los tipos junto al dominio que describen
Muchos proyectos comienzan con un único types.ts compartido. Al principio resulta práctico, pero luego cada desarrollador lo modifica, y un año después contiene cientos de definiciones no relacionadas entre sí. Encontrar el tipo adecuado se convierte en una búsqueda a nivel global, y el archivo se transforma en un foco de conflictos al fusionar cambios.
Una opción mejor por defecto es colocar los tipos junto al código del dominio al que pertenecen:
users/
user.types.ts
user.service.ts
user.repository.ts
payments/
payment.types.ts
payment.service.ts
payment.repository.ts
facilities/
facility.types.ts
facility.service.ts
facility.repository.ts
La estructura exacta de las carpetas importa menos que la regla que la rige: un tipo debe estar junto al dominio que describe. Si sabes dónde se encuentra la lógica de negocio relacionada con los pagos, deberías poder adivinar también dónde se encuentran los tipos de pago. Los tipos verdaderamente transversales, como las estructuras comunes de API, pueden seguir estando en un módulo pequeño y compartido.
9. Mantén el sistema de tipos más simple que la lógica de negocio
TypeScript ofrece tipos mapeados, tipos condicionales, tipos de texto literal con plantillas, tipos recursivos, infer y condicionales distributivos. Con estas herramientas se puede crear casi cualquier cosa a nivel de tipos, y ese es precisamente el motivo por el que la moderación es importante. Considere una función auxiliar como esta, que filtra un objeto hasta quedarse solo con las claves que terminan en Id:
type Magic<T> =
T extends infer U
? U extends Record<string, unknown>
? {
[K in keyof U as K extends `${string}Id`
? K
: never]: U[K]
}
: never
: never
La programación a nivel de tipo tiene usos legítimos, especialmente en bibliotecas. Pero llega un punto en el que un tipo añade más complejidad de la que elimina. Si un compañero debe decodificar un tipo elaborado antes de poder seguir la regla de negocio que respalda, pregúntese si una versión más simple sería suficiente. A veces la respuesta es no y la complejidad está justificada; con frecuencia, no lo está. La astucia no es sinónimo de calidad. Los tipos aburridos pero fáciles de leer suelen superar a los impresionantes, y cuando realmente se necesita un tipo avanzado, un breve comentario y unos pocos tests de tipo hacen que sea mucho más fácil de mantener.
10. Configure tsconfig de forma intencionada
Una de las formas más sencillas de debilitar TypeScript es mediante una configuración que ignora los problemas exactos que se esperan que detecte. Como mínimo, debe saber qué función tienen estas opciones:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
strict activa una serie de verificaciones, incluidas strictNullChecks y noImplicitAny. Las otras dos son opciones independientes que strict no activa: noUncheckedIndexedAccess agrega el valor undefined a las lecturas indexadas de arrays y registros, mientras que exactOptionalPropertyTypes distingue entre una propiedad que falta y una que ha sido establecida explícitamente en undefined. Ambas pueden generar muchos errores en un proyecto existente.
La combinación adecuada depende del código base. Un proyecto heredado podría no poder activar todo de inmediato, y es perfectamente razonable habilitar las opciones gradualmente. Lo importante es que el equipo sepa qué está y no está verificando el compilador, comenzando por esta:
"strict": true
El modo estricto no existe para hacer que TypeScript sea tedioso. Su propósito es que el compilador sea honesto respecto a las incertidumbres, que es precisamente la razón de usar TypeScript: detectar problemas antes de que lo hagan los usuarios.
Por qué juntos son importantes estos hábitos
Ninguna de estas prácticas tiene valor como truco. Su valor radica en que facilitan la comprensión del código. Imagina a un nuevo compañero de equipo que se encuentra con este tipo:
type Payment =
| {
status: 'SUCCESS'
transactionId: string
}
| {
status: 'FAILED'
error: string
}
Sin necesidad de leer ninguna implementación, aprende una regla de negocio: un pago exitoso lleva un ID de transacción, mientras que uno fallido lleva un error. El tipo comunica cómo se comporta esa parte del sistema, y no solo que alguna propiedad es una cadena de texto. Ese es el estándar al que se debe aspirar.
Lista de verificación para revisiones de código
Antes de guardar cambios en TypeScript, revisa estas preguntas:
- ¿Podría este
anyserunknowno un tipo específico? - ¿Está esta anotación repitiendo algo que el compilador ya ha inferido?
- ¿Los tipos representan únicamente estados válidos del dominio?
- ¿Es esta propiedad opcional porque realmente lo es, o por conveniencia?
- ¿Describiría una unión este estado con mayor precisión?
- ¿Se utiliza aquí
asporque el valor es comprobablemente seguro, o solo para que desaparezca un error? - ¿Expresa este genérico una relación real entre tipos?
- ¿Es la nueva abstracción más fácil de entender que el código que reemplaza?
- ¿Puede un lector saber qué significa cada argumento en el lugar de la llamada?
- ¿Aclararía
readonlyla propiedad de pertenencia o inmutabilidad? - ¿Se dará cuenta el compilador cuando cambie este dominio?
- ¿Puede un compañero de equipo entender este tipo sin decodificarlo?
La pregunta final suele ser la más importante.
Conclusión: los tipos como herramienta de diseño
Con la experiencia, la sintaxis se vuelve la parte menos interesante de TypeScript. Lo que realmente importa es lo que elijas expresar. Puedes describir un objeto que contiene algunas cadenas de texto, o bien puedes describir una operación que siempre se encuentra en uno de cuatro estados, cada uno garantizando un conjunto específico de propiedades. La segunda opción es mucho más útil.
Por lo tanto, un buen código en TypeScript no se juzga por la cantidad de características avanzadas que pueda recitar un desarrollador, sino por cuán bien el sistema de tipos ayuda al equipo a comprender, modificar y mantener el software. Cuando el compilador aplica las reglas en las que ya depende la aplicación, los tipos dejan de ser una red de seguridad y pasan a formar parte de la arquitectura.
- Utiliza genéricos para las relaciones, y firmas simples cuando no haya ninguna relación que capturar.
readonly, formas precisas y objetos de opciones.tsconfig y ajustarlo intencionadamente.Lecturas relacionadas
- Diez patrones de TypeScript que convierten errores en el tiempo de ejecución en errores de compilación — Aprende diez técnicas de TypeScript, desde uniones discriminadas y satisfies hasta tipos marcados e infer, que permiten al compilador rechazar estados inválidos antes de que se envíe el código.
- Comportamientos de TypeScript que sorprenden a los desarrolladores experimentados, y por qué — Tipado estructural, verificaciones excesivas de propiedades, los tipos as const, satisfies, condicionales y mapeados, además de los principios de diseño que los convierten en código más seguro.