Desbloquea el poder de TypeScript con tipos condicionales y mapeados avanzados. Aprende a crear aplicaciones flexibles y seguras que se adaptan a estructuras de datos complejas. Domina el arte de escribir código TypeScript verdaderamente dinámico.
Patrones Avanzados de TypeScript: Dominio de Tipos Condicionales y Mapeados
El poder de TypeScript radica en su capacidad para proporcionar un tipado fuerte, permitiéndote detectar errores de forma temprana y escribir código más mantenible. Aunque los tipos básicos como string
, number
y boolean
son fundamentales, las características avanzadas de TypeScript como los tipos condicionales y mapeados desbloquean una nueva dimensión de flexibilidad y seguridad de tipos. Esta guía completa profundizará en estos potentes conceptos, equipándote con el conocimiento para crear aplicaciones TypeScript verdaderamente dinámicas y adaptables.
¿Qué son los Tipos Condicionales?
Los tipos condicionales te permiten definir tipos que dependen de una condición, de forma similar a un operador ternario en JavaScript (condition ? trueValue : falseValue
). Te permiten expresar relaciones de tipos complejas basadas en si un tipo satisface una restricción específica.
Sintaxis
La sintaxis básica para un tipo condicional es:
T extends U ? X : Y
T
: El tipo que se está comprobando.U
: El tipo con el que se va a comprobar.extends
: La palabra clave que indica una relación de subtipo.X
: El tipo a usar siT
es asignable aU
.Y
: El tipo a usar siT
no es asignable aU
.
En esencia, si T extends U
se evalúa como verdadero, el tipo se resuelve a X
; de lo contrario, se resuelve a Y
.
Ejemplos Prácticos
1. Determinar el Tipo de un Parámetro de Función
Digamos que quieres crear un tipo que determine si el parámetro de una función es una cadena de texto o un número:
type ParamType<T> = T extends string ? string : number;
function processValue(value: ParamType<string | number>): void {
if (typeof value === "string") {
console.log("Value is a string:", value);
} else {
console.log("Value is a number:", value);
}
}
processValue("hello"); // Output: Value is a string: hello
processValue(123); // Output: Value is a number: 123
En este ejemplo, ParamType<T>
es un tipo condicional. Si T
es una cadena de texto, el tipo se resuelve a string
; de lo contrario, se resuelve a number
. La función processValue
acepta una cadena de texto o un número basándose en este tipo condicional.
2. Extraer el Tipo de Retorno Basado en el Tipo de Entrada
Imagina un escenario donde tienes una función que devuelve diferentes tipos según la entrada. Los tipos condicionales pueden ayudarte a definir el tipo de retorno correcto:
interface StringProcessor {
process(input: string): number;
}
interface NumberProcessor {
process(input: number): string;
}
type Processor<T> = T extends string ? StringProcessor : NumberProcessor;
function createProcessor<T extends string | number>(input: T): Processor<T> {
if (typeof input === "string") {
return { process: (input: string) => input.length } as Processor<T>;
} else {
return { process: (input: number) => input.toString() } as Processor<T>;
}
}
const stringProcessor = createProcessor("example");
const numberProcessor = createProcessor(42);
console.log(stringProcessor.process("example")); // Output: 7
console.log(numberProcessor.process(42)); // Output: "42"
Aquí, el tipo Processor<T>
selecciona condicionalmente StringProcessor
o NumberProcessor
según el tipo de la entrada. Esto asegura que la función createProcessor
devuelva el tipo correcto de objeto procesador.
3. Uniones Discriminadas
Los tipos condicionales son extremadamente potentes cuando se trabaja con uniones discriminadas. Una unión discriminada es un tipo de unión donde cada miembro tiene una propiedad de tipo singleton común (el discriminante). Esto te permite acotar el tipo basándote en el valor de esa propiedad.
interface Square {
kind: "square";
size: number;
}
interface Circle {
kind: "circle";
radius: number;
}
type Shape = Square | Circle;
type Area<T extends Shape> = T extends { kind: "square" } ? number : string;
function calculateArea(shape: Shape): Area<typeof shape> {
if (shape.kind === "square") {
return shape.size * shape.size;
} else {
return Math.PI * shape.radius * shape.radius;
}
}
const mySquare: Square = { kind: "square", size: 5 };
const myCircle: Circle = { kind: "circle", radius: 3 };
console.log(calculateArea(mySquare)); // Output: 25
console.log(calculateArea(myCircle)); // Output: 28.274333882308138
En este ejemplo, el tipo Shape
es una unión discriminada. El tipo Area<T>
utiliza un tipo condicional para determinar si la forma es un cuadrado o un círculo, devolviendo un number
para los cuadrados y un string
para los círculos (aunque en un escenario del mundo real, probablemente querrías tipos de retorno consistentes, esto demuestra el principio).
Puntos Clave sobre los Tipos Condicionales
- Permiten definir tipos basados en condiciones.
- Mejoran la seguridad de tipos al expresar relaciones de tipos complejas.
- Son útiles para trabajar con parámetros de función, tipos de retorno y uniones discriminadas.
¿Qué son los Tipos Mapeados?
Los tipos mapeados proporcionan una forma de transformar tipos existentes mapeando sobre sus propiedades. Te permiten crear nuevos tipos basados en las propiedades de otro tipo, aplicando modificaciones como hacer las propiedades opcionales, de solo lectura o cambiar sus tipos.
Sintaxis
La sintaxis general para un tipo mapeado es:
type NewType<T> = {
[K in keyof T]: ModifiedType;
};
T
: El tipo de entrada.keyof T
: Un operador de tipo que devuelve una unión de todas las claves de propiedad enT
.K in keyof T
: Itera sobre cada clave enkeyof T
, asignando cada clave a la variable de tipoK
.ModifiedType
: El tipo al que se mapeará cada propiedad. Esto puede incluir tipos condicionales u otras transformaciones de tipo.
Ejemplos Prácticos
1. Hacer Propiedades Opcionales
Puedes usar un tipo mapeado para hacer que todas las propiedades de un tipo existente sean opcionales:
interface User {
id: number;
name: string;
email: string;
}
type PartialUser = {
[K in keyof User]?: User[K];
};
const partialUser: PartialUser = {
name: "John Doe",
}; // Válido, ya que 'id' y 'email' son opcionales
Aquí, PartialUser
es un tipo mapeado que itera sobre las claves de la interfaz User
. Para cada clave K
, hace la propiedad opcional añadiendo el modificador ?
. El tipo User[K]
recupera el tipo de la propiedad K
de la interfaz User
.
2. Hacer Propiedades de Solo Lectura
De manera similar, puedes hacer que todas las propiedades de un tipo existente sean de solo lectura:
interface Product {
id: number;
name: string;
price: number;
}
type ReadonlyProduct = {
readonly [K in keyof Product]: Product[K];
};
const readonlyProduct: ReadonlyProduct = {
id: 123,
name: "Example Product",
price: 25.00,
};
// readonlyProduct.price = 30.00; // Error: No se puede asignar a 'price' porque es una propiedad de solo lectura.
En este caso, ReadonlyProduct
es un tipo mapeado que añade el modificador readonly
a cada propiedad de la interfaz Product
.
3. Transformar Tipos de Propiedad
Los tipos mapeados también se pueden usar para transformar los tipos de las propiedades. Por ejemplo, puedes crear un tipo que convierta todas las propiedades de tipo cadena de texto a números:
interface Config {
apiUrl: string;
timeout: string;
maxRetries: number;
}
type NumericConfig = {
[K in keyof Config]: Config[K] extends string ? number : Config[K];
};
const numericConfig: NumericConfig = {
apiUrl: 123, // Debe ser un número debido al mapeo
timeout: 456, // Debe ser un número debido al mapeo
maxRetries: 3,
};
Este ejemplo demuestra el uso de un tipo condicional dentro de un tipo mapeado. Para cada propiedad K
, comprueba si el tipo de Config[K]
es una cadena de texto. Si lo es, el tipo se mapea a number
; de lo contrario, permanece sin cambios.
4. Remapeo de Claves (desde TypeScript 4.1)
TypeScript 4.1 introdujo la capacidad de remapear claves dentro de los tipos mapeados usando la palabra clave as
. Esto te permite crear nuevos tipos con diferentes nombres de propiedad basados en el tipo original.
interface Event {
eventId: string;
eventName: string;
eventDate: Date;
}
type TransformedEvent = {
[K in keyof Event as `new${Capitalize<string&K>}`]: Event[K];
};
// Resultado:
// {
// newEventId: string;
// newEventName: string;
// newEventDate: Date;
// }
//Función Capitalize usada para poner en mayúscula la primera letra
type Capitalize<S extends string> = Uppercase<string&S> extends never ? string : `$Capitalize<S>`;
//Uso con un objeto real
const myEvent: TransformedEvent = {
newEventId: "123",
newEventName: "New Name",
newEventDate: new Date()
};
Aquí, el tipo TransformedEvent
remapea cada clave K
a una nueva clave con el prefijo "new" y en mayúscula. La función de utilidad `Capitalize`, asegura que la primera letra de la clave esté en mayúscula. La intersección `string & K` asegura que solo estamos tratando con claves de tipo cadena y que estamos obteniendo el tipo literal correcto de K. El remapeo de claves abre potentes posibilidades para transformar y adaptar tipos a necesidades específicas. Esto te permite renombrar, filtrar o modificar claves basándote en lógica compleja.
Puntos Clave sobre los Tipos Mapeados
- Permiten transformar tipos existentes mapeando sobre sus propiedades.
- Permiten hacer propiedades opcionales, de solo lectura o cambiar sus tipos.
- Son útiles para crear nuevos tipos basados en las propiedades de otro tipo.
- El remapeo de claves (introducido en TypeScript 4.1) ofrece una flexibilidad aún mayor en las transformaciones de tipo.
Combinando Tipos Condicionales y Mapeados
El verdadero poder de los tipos condicionales y mapeados se manifiesta cuando los combinas. Esto te permite crear definiciones de tipo altamente flexibles y expresivas que pueden adaptarse a una amplia gama de escenarios.
Ejemplo: Filtrar Propiedades por Tipo
Digamos que quieres crear un tipo que filtre las propiedades de un objeto basándose en su tipo. Por ejemplo, puede que quieras extraer solo las propiedades de tipo cadena de texto de un objeto.
interface Data {
name: string;
age: number;
city: string;
country: string;
isEmployed: boolean;
}
type StringProperties<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type StringData = StringProperties<Data>;
// Resultado:
// {
// name: string;
// city: string;
// country: string;
// }
const stringData: StringData = {
name: "John",
city: "New York",
country: "USA",
};
En este ejemplo, el tipo StringProperties<T>
utiliza un tipo mapeado con remapeo de claves y un tipo condicional. Para cada propiedad K
, comprueba si el tipo de T[K]
es una cadena de texto. Si lo es, la clave se mantiene; de lo contrario, se mapea a never
, lo que efectivamente la filtra. Usar never
como clave de un tipo mapeado la elimina del tipo resultante. Esto asegura que solo las propiedades de tipo cadena se incluyan en el tipo StringData
.
Tipos de Utilidad en TypeScript
TypeScript proporciona varios tipos de utilidad integrados que aprovechan los tipos condicionales y mapeados para realizar transformaciones de tipo comunes. Entender estos tipos de utilidad puede simplificar significativamente tu código y mejorar la seguridad de tipos.
Tipos de Utilidad Comunes
Partial<T>
: Hace que todas las propiedades deT
sean opcionales.Readonly<T>
: Hace que todas las propiedades deT
sean de solo lectura.Required<T>
: Hace que todas las propiedades deT
sean requeridas (elimina el modificador?
).Pick<T, K extends keyof T>
: Selecciona un conjunto de propiedadesK
deT
.Omit<T, K extends keyof T>
: Elimina un conjunto de propiedadesK
deT
.Record<K extends keyof any, T>
: Construye un tipo con un conjunto de propiedadesK
de tipoT
.Exclude<T, U>
: Excluye deT
todos los tipos que son asignables aU
.Extract<T, U>
: Extrae deT
todos los tipos que son asignables aU
.NonNullable<T>
: Excluyenull
yundefined
deT
.Parameters<T>
: Obtiene los parámetros de un tipo de funciónT
en una tupla.ReturnType<T>
: Obtiene el tipo de retorno de un tipo de funciónT
.InstanceType<T>
: Obtiene el tipo de instancia de un tipo de función constructoraT
.ThisType<T>
: Sirve como un marcador para el tipo contextual dethis
.
Estos tipos de utilidad están construidos usando tipos condicionales y mapeados, demostrando el poder y la flexibilidad de estas características avanzadas de TypeScript. Por ejemplo, Partial<T>
se define como:
type Partial<T> = {
[P in keyof T]?: T[P];
};
Mejores Prácticas para Usar Tipos Condicionales y Mapeados
Aunque los tipos condicionales y mapeados son potentes, también pueden hacer que tu código sea más complejo si no se usan con cuidado. Aquí hay algunas mejores prácticas a tener en cuenta:
- Mantenlo Simple: Evita tipos condicionales y mapeados demasiado complejos. Si una definición de tipo se vuelve muy enrevesada, considera dividirla en partes más pequeñas y manejables.
- Usa Nombres Significativos: Dale a tus tipos condicionales y mapeados nombres descriptivos que indiquen claramente su propósito.
- Documenta tus Tipos: Añade comentarios para explicar la lógica detrás de tus tipos condicionales y mapeados, especialmente si son complejos.
- Aprovecha los Tipos de Utilidad: Antes de crear un tipo condicional o mapeado personalizado, comprueba si un tipo de utilidad integrado puede lograr el mismo resultado.
- Prueba tus Tipos: Asegúrate de que tus tipos condicionales y mapeados se comporten como se espera escribiendo pruebas unitarias que cubran diferentes escenarios.
- Considera el Rendimiento: Los cálculos de tipos complejos pueden afectar los tiempos de compilación. Ten en cuenta las implicaciones de rendimiento de tus definiciones de tipo.
Conclusión
Los tipos condicionales y mapeados son herramientas esenciales para dominar TypeScript. Te permiten crear aplicaciones altamente flexibles, seguras en tipo y mantenibles que se adaptan a estructuras de datos complejas y requisitos dinámicos. Al comprender y aplicar los conceptos discutidos en esta guía, puedes desbloquear todo el potencial de TypeScript y escribir código más robusto y escalable. A medida que continúes explorando TypeScript, recuerda experimentar con diferentes combinaciones de tipos condicionales y mapeados para descubrir nuevas formas de resolver problemas de tipado desafiantes. Las posibilidades son verdaderamente infinitas.