tutoriales.com

Tipos Utilitarios en TypeScript: Potenciando Tu Código con Mapped Types y Condicionales

Este tutorial profundiza en los tipos utilitarios de TypeScript, mostrando cómo Mapped Types y tipos condicionales permiten transformar y adaptar tipos existentes. Aprenderás a crear tipos más flexibles, reutilizables y seguros, mejorando la calidad y mantenibilidad de tu código.

Avanzado18 min read137 views
Report error

TypeScript, más allá de ser un superset de JavaScript, ofrece un sistema de tipos increíblemente potente y flexible. Uno de los pilares de esta flexibilidad son los tipos utilitarios. Estos no son solo tipos predefinidos como Partial o Required, sino un conjunto de herramientas y conceptos que nos permiten construir y transformar tipos de maneras muy dinámicas.

En este tutorial, exploraremos los fundamentos de los tipos utilitarios, nos sumergiremos en los poderosos Mapped Types y desentrañaremos la lógica de los tipos condicionales. Al final, serás capaz de crear tus propios tipos utilitarios avanzados, adaptando TypeScript a las necesidades exactas de tu proyecto.

🛠️ ¿Qué son los Tipos Utilitarios en TypeScript?

Los tipos utilitarios son funciones a nivel de tipo que toman uno o más tipos como entrada y devuelven un nuevo tipo transformado. Piensa en ellos como transformadores de tipos. TypeScript ya viene con una serie de utilitarios incorporados que son ampliamente conocidos y usados, como Partial<T>, Readonly<T>, Pick<T, K>, y Omit<T, K>.

Estos utilitarios nos permiten, por ejemplo, hacer que todas las propiedades de un tipo sean opcionales (Partial), o seleccionar solo un subconjunto de propiedades (Pick). Pero, ¿cómo funcionan por dentro? Y más importante aún, ¿cómo podemos crear los nuestros para resolver problemas específicos?

La clave para entender y crear tipos utilitarios personalizados reside en dos conceptos fundamentales:

  1. Mapped Types (Tipos Mapeados)
  2. Conditional Types (Tipos Condicionales)

Combinando estos, podemos construir lógicas de tipos muy sofisticadas.

🚀 Mapped Types: Transformando Propiedades de Objetos

Los Mapped Types son una característica potente que nos permite crear nuevos tipos de objetos transformando las propiedades de un tipo existente. Son una forma genérica de iterar sobre las propiedades de un tipo y aplicar alguna modificación a cada una de ellas.

La sintaxis básica de un Mapped Type es similar a una comprensión de lista en otros lenguajes, pero operando sobre claves de tipo:

type MyMappedType<T> = {
  [P in keyof T]: T[P];
};

Aquí, [P in keyof T] itera sobre todas las claves P del tipo T. Para cada clave, T[P] representa el tipo de la propiedad correspondiente en T. Podemos modificar el tipo de T[P] o añadir modificadores.

📝 Sintaxis Básica de Mapped Types

Veamos un ejemplo práctico. Supongamos que tenemos un tipo User:

interface User {
  id: number;
  name: string;
  email: string;
  isActive: boolean;
}

Ahora, queremos crear un tipo donde todas las propiedades de User sean readonly. Podríamos hacerlo manualmente, pero un Mapped Type es mucho más eficiente:

type ReadonlyUser = {
  readonly [P in keyof User]: User[P];
};

// Equivalente a:
// type ReadonlyUser = {
//   readonly id: number;
//   readonly name: string;
//   readonly email: string;
//   readonly isActive: boolean;
// };

const user: ReadonlyUser = { id: 1, name: "Alice", email: "a@example.com", isActive: true };
// user.name = "Bob"; // Error: Cannot assign to 'name' because it is a read-only property.
📌 Nota: Este es precisamente cómo el utilitario `Readonly` está implementado en la biblioteca estándar de TypeScript.

Modificadores de Propiedades con Mapped Types

Además de transformar el tipo de valor, podemos añadir o remover modificadores de propiedad como readonly y ? (opcional).

  • Añadir readonly: readonly [P in keyof T]: T[P];
  • Remover readonly: -readonly [P in keyof T]: T[P];
  • Añadir ? (opcional): [P in keyof T]?: T[P];
  • Remover ? (requerido): [P in keyof T]-?: T[P];

Veamos un ejemplo para hacer todas las propiedades opcionales (como Partial<T>):

type MakeOptional<T> = {
  [P in keyof T]?: T[P];
};

interface Product {
  id: number;
  name: string;
  price: number;
  description: string;
}

type PartialProduct = MakeOptional<Product>;
// type PartialProduct = {
//   id?: number;
//   name?: string;
//   price?: number;
//   description?: string;
// };

const partial: PartialProduct = { name: "Laptop" }; // Válido

Y para remover la opcionalidad (como Required<T>):

type MakeRequired<T> = {
  [P in keyof T]-?: T[P];
};

interface Car {
  brand?: string;
  model?: string;
  year?: number;
}

type RequiredCar = MakeRequired<Car>;
// type RequiredCar = {
//   brand: string;
//   model: string;
//   year: number;
// };

const myCar: RequiredCar = { brand: "Tesla", model: "Model 3", year: 2023 };
// const invalidCar: RequiredCar = { brand: "BMW" }; // Error: Property 'model' is missing...

🔑 Remapeo de Claves con as

TypeScript 4.1 introdujo el operador as en Mapped Types, permitiendo remapear (cambiar el nombre) de las claves. Esto es increíblemente útil para transformar el formato de las propiedades.

La sintaxis es [P in keyof T as NewKeyType]: T[P];.

Por ejemplo, si queremos añadir un prefijo get a cada propiedad:

type Getters<T> = {
  [P in keyof T as `get${Capitalize<string & P>}`]: () => T[P];
};

interface Data {
  name: string;
  age: number;
}

type DataGetters = Getters<Data>;
// type DataGetters = {
//   getName: () => string;
//   getAge: () => number;
// };

const myData: DataGetters = {
  getName: () => "John Doe",
  getAge: () => 30,
};

console.log(myData.getName()); // "John Doe"

Aquí, Capitalize<string & P> es otro tipo utilitario que convierte la primera letra de una cadena en mayúscula. string & P asegura que P sea tratado como un string para el tipo Capitalize.

💡 Consejo: El remapeo de claves es muy útil para generar tipos para APIs que usan convenciones de nombres diferentes (por ejemplo, snake_case vs. camelCase) o para generar *getters* y *setters* automáticamente.

🔮 Tipos Condicionales: Lógica if-else en el Sistema de Tipos

Los tipos condicionales nos permiten modelar decisiones lógicas en el sistema de tipos de TypeScript. Su sintaxis es SomeType extends OtherType ? TrueType : FalseType.

Esto significa: "Si SomeType es asignable a OtherType, entonces el tipo resultante es TrueType; de lo contrario, es FalseType."

🎯 Uso Básico de Tipos Condicionales

Consideremos un ejemplo simple. Queremos un tipo que sea string si un tipo genérico T es boolean, y number en caso contrario:

type StringOrNumber<T> = T extends boolean ? string : number;

type Result1 = StringOrNumber<boolean>; // string
type Result2 = StringOrNumber<number>;  // number
type Result3 = StringOrNumber<string>;  // number

infer: Extrayendo Tipos en Condicionales

El operador infer es una de las características más potentes de los tipos condicionales. Nos permite extraer un tipo de una posición dentro del tipo que estamos probando. Esto es particularmente útil para trabajar con funciones, promesas y otras estructuras de tipos complejos.

La sintaxis es infer U (donde U es el nombre que le damos al tipo inferido).

Ejemplo: Extrayendo el Tipo de Retorno de una Función (ReturnType<T>)

El utilitario ReturnType<T> de TypeScript se implementa con infer:

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

function greet(): string {
  return "Hello";
}

function add(a: number, b: number): number {
  return a + b;
}

interface UserFetcher {
  (): { name: string; age: number };
}

type GreetReturn = MyReturnType<typeof greet>; // string
type AddReturn = MyReturnType<typeof add>;     // number
type UserReturn = MyReturnType<UserFetcher>;   // { name: string; age: number }
type NotAFunctionReturn = MyReturnType<string>; // any (no es una función)

Aquí, T extends (...args: any[]) => infer R comprueba si T es una función. Si lo es, infer R captura el tipo de retorno de esa función y lo asigna a R, que luego se devuelve como el tipo resultante.

Ejemplo: Extrayendo el Tipo de los Parámetros de una Función (Parameters<T>)

Similarmente, Parameters<T> se usa para obtener el tipo de los argumentos de una función:

type MyParameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;

function multiply(a: number, b: number, c: number): number {
  return a * b * c;
}

type MultiplyParams = MyParameters<typeof multiply>; // [a: number, b: number, c: number]

const params: MultiplyParams = [1, 2, 3];
🔥 Importante: `infer` solo se puede usar en el lado `extends` de un tipo condicional.

🧩 Combinando Mapped Types y Tipos Condicionales

La verdadera potencia de los tipos utilitarios se revela cuando combinamos Mapped Types con tipos condicionales. Esto nos permite crear transformaciones de tipos extremadamente flexibles y dinámicas.

Ejemplo: NonFunctionProperties<T>

Imagina que quieres un tipo que contenga todas las propiedades de un objeto excepto las que son funciones. Esto es útil si quieres serializar un objeto o copiar solo sus datos.

type NonFunctionPropertyNames<T> = {
  [K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];

type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;

interface Gadget {
  id: number;
  name: string;
  description: string;
  price: number;
  activate(): void;
  deactivate: () => void;
}

type GadgetData = NonFunctionProperties<Gadget>;
// type GadgetData = {
//   id: number;
//   name: string;
//   description: string;
//   price: number;
// };

const myGadget: GadgetData = {
  id: 1,
  name: "Smartwatch",
  description: "Tracks health",
  price: 199.99
};

// const invalidGadget: GadgetData = { ...myGadget, activate: () => {} }; // Error

Desglosemos NonFunctionPropertyNames<T>:

  1. [K in keyof T]: ...: Iteramos sobre todas las claves K del tipo T.
  2. T[K] extends Function ? never : K;: Para cada propiedad, usamos un tipo condicional.
    • Si el tipo de la propiedad T[K] extiende Function (es decir, es una función), el resultado es never.
    • De lo contrario, el resultado es la propia clave K.
  3. { ... }[keyof T];: Esto crea un objeto temporal donde las propiedades de función tienen el tipo never. Al usar [keyof T] al final, TypeScript crea un union type de todos los valores de las propiedades de ese objeto. Las propiedades con tipo never son automáticamente descartadas de los union types resultantes. Esto nos da un union de solo los nombres de las propiedades que no son funciones.

Luego, NonFunctionProperties<T> simplemente usa el utilitario Pick<T, K> para seleccionar esas propiedades no funcionales del tipo original T.

Tipo T (e.g., Gadget) Entrada de la utilidad Mapped Type: { [K in keyof T]: ... } Itera sobre cada clave K de T ¿T[K] extends Function? Tipo condicional para cada valor SÍ (Es función) NO never Clave "K" Tipo de Objeto Temporal { id: "id", name: "name", activate: never, price: "price" } Acceso: [keyof T] Convierte valores en una Unión Resultado: Union Type 'id' | 'name' | 'price' (never se excluye de uniones)

Ejemplo: Mutable<T>

Supongamos que tienes un tipo Readonly<T> y necesitas una versión mutable de él. Puedes usar Mapped Types para quitar el modificador readonly:

type Mutable<T> = {
  -readonly [P in keyof T]: T[P];
};

interface UserProfile {
  readonly id: string;
  readonly username: string;
  readonly email: string;
}

type ModifiableProfile = Mutable<UserProfile>;
// type ModifiableProfile = {
//   id: string;
//   username: string;
//   email: string;
// };

const profile: ModifiableProfile = { id: "123", username: "johndoe", email: "john@example.com" };
profile.username = "janedoe"; // Válido

Ejemplo Avanzado: DeepPartial<T>

El utilitario Partial<T> solo hace opcionales las propiedades de nivel superior. ¿Qué pasa si queremos hacer todas las propiedades, incluyendo las de objetos anidados, opcionales? Necesitamos una implementación recursiva usando tipos condicionales.

type DeepPartial<T> = T extends object ? {
  [P in keyof T]?: DeepPartial<T[P]>;
} : T;

interface Config {
  appName: string;
  version: string;
  database: {
    host: string;
    port: number;
    user?: string;
  };
  features: {
    logging: boolean;
    analytics: boolean;
  };
}

type PartialConfig = DeepPartial<Config>;
/*
// type PartialConfig = {
//   appName?: string;
//   version?: string;
//   database?: {
//     host?: string;
//     port?: number;
//     user?: string;
//   };
//   features?: {
//     logging?: boolean;
//     analytics?: boolean;
//   };
// }
*/

const myPartialConfig: PartialConfig = {
  database: {
    host: "localhost"
  },
  features: {
    logging: true
  }
};

console.log(myPartialConfig.database?.host); // "localhost"

Aquí, la lógica es:

  1. T extends object ? ... : T: Primero, comprobamos si T es un object (lo que incluye interfaces, types de objeto, etc., pero excluye tipos primitivos como string, number, boolean).
  2. Si T es un object:
    • [P in keyof T]?: ...: Aplicamos un Mapped Type para hacer todas sus propiedades opcionales.
    • DeepPartial<T[P]>: Y recursivamente llamamos a DeepPartial en el tipo de cada propiedad T[P]. Esto asegura que los objetos anidados también se hagan opcionales profundamente.
  3. Si T no es un object (es un tipo primitivo), simplemente devolvemos T tal cual, terminando la recursión.

Esto demuestra la increíble flexibilidad que ofrecen los tipos condicionales junto con los Mapped Types para crear transformaciones de tipos recursivas y complejas.

⚠️ Advertencia: Ten cuidado al usar tipos utilitarios avanzados. Si bien son muy poderosos, también pueden hacer que el código sea más difícil de entender para los recién llegados. Documenta bien tus tipos personalizados.

💡 Otros Operadores y Utilidades para Tipos

Además de Mapped Types y tipos condicionales, existen otros operadores clave que complementan la creación de tipos utilitarios:

keyof Operator

El operador keyof toma un tipo de objeto y produce un union type de sus claves de propiedad. Ya lo hemos visto en uso con Mapped Types ([P in keyof T]).

interface Person {
  name: string;
  age: number;
}

type PersonKeys = keyof Person; // "name" | "age"

let k: PersonKeys = "name";
// k = "address"; // Error: Type '"address"' is not assignable to type '"name" | "age"'.

typeof Operator

El operador typeof se usa en un contexto de tipo para obtener el tipo de una variable o propiedad en tiempo de ejecución. Esto es crucial para derivar tipos de valores JavaScript existentes.

const userSettings = {
  theme: "dark",
  notifications: true,
};

type Settings = typeof userSettings;
/*
// type Settings = {
//   theme: string;
//   notifications: boolean;
// };
*/

function updateSettings(settings: Settings) {
  // ...
}

updateSettings({ theme: "light", notifications: false });

indexed access types (Lookup Types)

La sintaxis T[K] es un tipo de acceso indexado o lookup type. Permite obtener el tipo de una propiedad específica de un tipo de objeto.

interface Car {
  brand: string;
  model: string;
  year: number;
}

type CarBrand = Car["brand"]; // string
type CarYear = Car["year"];   // number

type AllCarValues = Car[keyof Car]; // string | number (union de todos los tipos de propiedad)

Este operador es fundamental para los Mapped Types, donde T[P] accede al tipo de la propiedad P dentro del tipo T.

📈 Aplicaciones Prácticas y Ejemplos Adicionales

Los tipos utilitarios avanzados abren un mundo de posibilidades para escribir código TypeScript más robusto y expresivo. Aquí algunas aplicaciones comunes:

1. Creación de Tipos de Entidad para ORM/Base de Datos

Al trabajar con bases de datos y ORMs, a menudo necesitas tipos para la creación, actualización y recuperación de entidades. Por ejemplo, al crear un nuevo usuario, el id suele ser generado por la base de datos y, por lo tanto, es opcional o no existe en la fase de creación.

interface DbUser {
  id: string;
  username: string;
  email: string;
  createdAt: Date;
  updatedAt: Date;
}

type UserCreateDto = Omit<DbUser, "id" | "createdAt" | "updatedAt">;
// type UserCreateDto = {
//   username: string;
//   email: string;
// };

type UserUpdateDto = Partial<UserCreateDto>;
// type UserUpdateDto = {
//   username?: string;
//   email?: string;
// };

const newUser: UserCreateDto = { username: "alice", email: "alice@example.com" };
const updateUser: UserUpdateDto = { email: "alice.new@example.com" };

2. Tipos para Formularios y Validaciones

Para formularios, podrías necesitar un tipo que haga todas las propiedades opcionales (para valores iniciales) y otro que mapee cada campo a un posible mensaje de error.

type FormErrors<T> = {
  [P in keyof T]?: string;
};

interface LoginForm {
  username: string;
  password: string;
}

type LoginFormErrors = FormErrors<LoginForm>;
// type LoginFormErrors = {
//   username?: string;
//   password?: string;
// };

const errors: LoginFormErrors = { username: "Required", password: "Min 8 chars" };

3. Tipos para Configuración Modular

Si tienes configuraciones que se mezclan o sobrescriben, DeepPartial es muy útil.

interface AppConfig {
  api: {
    baseUrl: string;
    timeout: number;
  };
  logging: {
    level: 'info' | 'warn' | 'error';
    filePath: string;
  };
}

const defaultAppConfig: AppConfig = {
  api: { baseUrl: "/api", timeout: 5000 },
  logging: { level: 'info', filePath: "app.log" }
};

const userProvidedConfig: DeepPartial<AppConfig> = {
  api: { timeout: 10000 },
  logging: { level: 'warn' }
};

// mergeConfigs es una función que combinaría estos objetos a nivel de runtime
// Su tipo de retorno sería AppConfig
// function mergeConfigs(defaultConfig: AppConfig, userConfig: DeepPartial<AppConfig>): AppConfig { /* ... */ }
Lógica de DeepPartial<T> Entrada: AppConfig (Objeto anidado) ¿T extends object? (¿Es estructura anidada?) NO Tipo Primitivo Retorna T Mapped Type Itera: 'api', 'logging' Partial<{ [K in keyof T]? }> Llamada Recursiva DeepPartial<T[K]> Fin: Todos los niveles son opcionales

4. Filtrar Propiedades por Tipo

Podemos combinar keyof, Mapped Types y tipos condicionales para crear tipos que filtran propiedades basándose en su tipo de valor.

type PickByType<T, U> = {
  [P in keyof T as T[P] extends U ? P : never]: T[P];
};

interface BlogPost {
  id: number;
  title: string;
  content: string;
  published: boolean;
  tags: string[];
  authorId: number;
}

type StringFields = PickByType<BlogPost, string>;
// type StringFields = {
//   title: string;
//   content: string;
// };

type BooleanFields = PickByType<BlogPost, boolean>;
// type BooleanFields = {
//   published: boolean;
// };

En PickByType, el truco está en la parte as T[P] extends U ? P : never. Si el tipo de la propiedad T[P] es asignable a U, entonces la clave P se mantiene; de lo contrario, se convierte en never, lo que efectivamente la elimina del tipo final.


✅ Conclusión

Los tipos utilitarios son una característica fundamental y extremadamente poderosa en TypeScript. Al dominar los Mapped Types, los tipos condicionales y operadores como keyof, typeof e indexed access types, puedes llevar tu código TypeScript a un nuevo nivel de seguridad, flexibilidad y expresividad.

Recuerda que el objetivo de TypeScript es ayudarte a escribir código más robusto y a detectar errores antes de que se conviertan en problemas en tiempo de ejecución. Los tipos utilitarios son herramientas esenciales en este arsenal, permitiéndote modelar dominios complejos y aplicar transformaciones de tipos con precisión quirúrgica.

¡Experimenta con ellos, crea tus propios utilitarios y observa cómo tu código se vuelve más limpio y mantenible!

Comments (0)

No comments yet. Be the first!