Una inmersión profunda en las características más poderosas del sistema de tipos de TypeScript: tipos condicionales, mapped types, la palabra clave infer y template literal types. Patrones reales que vas a usar de verdad.
La mayoría de los desarrolladores de TypeScript se estancan en el mismo punto. Conocen interfaces, genéricos, union types. Saben tipar un componente de React. Pueden hacer que las líneas rojas desaparezcan. Pero tratan el sistema de tipos como un mal necesario — algo que existe para prevenir bugs, no algo que los ayuda activamente a diseñar mejor software.
Yo me quedé estancado en ese punto durante unos dos años. Lo que me sacó de ahí fue darme cuenta de que el sistema de tipos de TypeScript es un lenguaje de programación en sí mismo. Tiene condicionales, bucles, pattern matching, recursión y manipulación de strings. Una vez que internalizas eso, todo cambia. Dejas de pelear contra el compilador y empiezas a colaborar con él.
Este post cubre las funcionalidades a nivel de tipos que uso constantemente en código de producción. No son ejercicios académicos — son patrones reales que me han salvado de bugs reales.
Antes de meternos en la sintaxis, quiero replantear cómo piensas sobre los tipos.
En programación a nivel de valores, escribes funciones que transforman datos en tiempo de ejecución:
function extractIds(users: User[]): number[] {
return users.map((u) => u.id);
}En programación a nivel de tipos, escribes tipos que transforman otros tipos en tiempo de compilación:
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];El modelo mental es el mismo — entrada, transformación, salida. La diferencia es que el código a nivel de tipos se ejecuta durante la compilación, no en runtime. No produce nada de JavaScript. Su único propósito es hacer que los estados imposibles sean irrepresentables.
Si alguna vez escribiste una función donde tres de los cuatro parámetros solo tienen sentido en ciertas combinaciones, o donde el tipo de retorno depende de lo que le pases, ya necesitabas programación a nivel de tipos. Solo que quizás no sabías que TypeScript podía expresarlo.
Los tipos condicionales son el if/else del sistema de tipos. La sintaxis se parece a un operador ternario:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
type C = IsString<string>; // trueLa palabra clave extends aquí no significa herencia. Significa "es asignable a". Piénsalo como "¿encaja T en la forma de string?"
Reconstruyamos algunos de los tipos utilitarios integrados de TypeScript para entender cómo funcionan.
NonNullable — remueve null y undefined de un union:
type MyNonNullable<T> = T extends null | undefined ? never : T;
type Test = MyNonNullable<string | null | undefined>;
// Result: stringEl tipo never es el "conjunto vacío" — remueve algo de un union. Cuando una rama de un tipo condicional resuelve a never, ese miembro del union simplemente desaparece.
Extract — mantiene solo los miembros del union que coinciden con una restricción:
type MyExtract<T, U> = T extends U ? T : never;
type Numbers = Extract<string | number | boolean, number | boolean>;
// Result: number | booleanExclude — lo opuesto, remueve los miembros que coinciden:
type MyExclude<T, U> = T extends U ? never : T;
type WithoutStrings = Exclude<string | number | boolean, string>;
// Result: number | booleanAquí es donde las cosas se ponen interesantes, y donde la mayoría de la gente se confunde. Cuando pasas un tipo union a un tipo condicional, TypeScript distribuye la condición sobre cada miembro del union individualmente.
type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>;
// You might expect: (string | number)[]
// Actual result: string[] | number[]TypeScript no evalúa (string | number) extends unknown. En cambio, evalúa string extends unknown y number extends unknown por separado, y luego une los resultados.
Es por eso que Extract y Exclude funcionan como lo hacen. La distribución sucede automáticamente cuando T es un parámetro de tipo "desnudo" (no envuelto en nada).
Si quieres prevenir la distribución, envuelve ambos lados en una tupla:
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type Result = ToArrayNonDist<string | number>;
// Result: (string | number)[]Me ha mordido esto más veces de las que me gustaría admitir. Si tu tipo condicional está dando resultados inesperados con inputs de tipo union, la distribución es casi siempre la razón.
Aquí hay un patrón que uso cuando manejo respuestas de API que pueden tener éxito o fallar:
type ApiResponse<T> = { status: "success"; data: T; error: never } | { status: "error"; data: never; error: string };
type ExtractData<R> = R extends { status: "success"; data: infer D } ? D : never;
type ExtractError<R> = R extends { status: "error"; error: infer E } ? E : never;
type UserResponse = ApiResponse<{ id: number; name: string }>;
type UserData = ExtractData<UserResponse>;
// Result: { id: number; name: string }
type UserError = ExtractError<UserResponse>;
// Result: stringEsto es más limpio que escribir type guards manuales para cada forma de respuesta.
inferinfer es pattern matching para tipos. Te permite declarar una variable de tipo dentro de un tipo condicional que TypeScript resolverá por ti.
Piénsalo como: "Sé que este tipo tiene cierta forma. Extráeme la parte que me interesa."
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // booleanTypeScript ve el patrón (...args) => something, hace match con tu función contra él, y asigna el tipo de retorno a R.
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
type Fn = (x: number, y: string) => void;
type Params = MyParameters<Fn>; // [x: number, y: string]Este lo uso todo el tiempo cuando trabajo con funciones asíncronas:
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnpackPromise<Promise<string>>; // string
type B = UnpackPromise<Promise<number[]>>; // number[]
type C = UnpackPromise<string>; // string (passthrough)Para promises profundamente anidadas:
type DeepUnpackPromise<T> = T extends Promise<infer U> ? DeepUnpackPromise<U> : T;
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Result: numbertype ElementOf<T> = T extends (infer E)[] ? E : never;
type Items = ElementOf<string[]>; // string
type Mixed = ElementOf<(string | number)[]>; // string | numberinfer en un solo tipoPuedes usar infer múltiples veces para extraer diferentes partes:
type FirstAndLast<T extends unknown[]> = T extends [infer First, ...unknown[], infer Last]
? { first: First; last: Last }
: never;
type Result = FirstAndLast<[1, 2, 3, 4]>;
// Result: { first: 1; last: 4 }Aquí hay un patrón que uso en sistemas orientados a eventos:
type EventMap = {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"order:created": { orderId: string; total: number };
"order:shipped": { orderId: string; trackingId: string };
};
type EventName = keyof EventMap;
type EventPayload<E extends EventName> = EventMap[E];
// Extract all user-related events
type UserEvents = Extract<EventName, `user:${string}`>;
// Result: "user:login" | "user:logout"
// Get the payload for user events only
type UserEventPayloads = EventPayload<UserEvents>;
// Result: { userId: string; timestamp: number } | { userId: string }Los mapped types te permiten crear nuevos tipos de objeto transformando cada propiedad de un tipo existente. Son el map() del sistema de tipos.
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Partial<T> = {
[K in keyof T]?: T[K];
};keyof T te da un union de todas las claves de propiedades. [K in ...] itera sobre cada una. T[K] es el tipo de esa propiedad.
Required — hace todas las propiedades obligatorias:
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};La sintaxis -? remueve el modificador opcional. De manera similar, -readonly remueve readonly.
Pick — selecciona propiedades específicas:
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
interface User {
id: number;
name: string;
email: string;
password: string;
}
type PublicUser = MyPick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }Omit — remueve propiedades específicas:
type MyOmit<T, K extends keyof T> = {
[P in Exclude<keyof T, K>]: T[P];
};
type SafeUser = MyOmit<User, "password">;
// { id: number; name: string; email: string }Record — crea un tipo de objeto con claves y tipos de valor específicos:
type MyRecord<K extends string | number | symbol, V> = {
[P in K]: V;
};
type StatusMap = MyRecord<"active" | "inactive" | "banned", boolean>;
// { active: boolean; inactive: boolean; banned: boolean }asTypeScript 4.1 introdujo el remapeo de claves, que te permite transformar nombres de propiedades:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Person {
name: string;
age: number;
email: string;
}
type PersonGetters = Getters<Person>;
// {
// getName: () => string;
// getAge: () => number;
// getEmail: () => string;
// }También puedes filtrar propiedades remapeando a never:
type OnlyStrings<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type StringProps = OnlyStrings<Person>;
// { name: string; email: string }Esto es increíblemente poderoso. Estás simultáneamente iterando sobre propiedades, filtrándolas y transformando sus nombres — todo a nivel de tipos.
He usado este patrón en varias librerías de formularios:
interface FormFields {
username: string;
email: string;
age: number;
bio: string;
}
type ValidationErrors<T> = {
[K in keyof T]?: string[];
};
type TouchedFields<T> = {
[K in keyof T]?: boolean;
};
type DirtyFields<T> = {
[K in keyof T]?: boolean;
};
type FormState<T> = {
values: T;
errors: ValidationErrors<T>;
touched: TouchedFields<T>;
dirty: DirtyFields<T>;
isValid: boolean;
isSubmitting: boolean;
};
// Now your form state is fully typed based on the fields
type MyFormState = FormState<FormFields>;Agregas un campo a FormFields, y cada tipo relacionado se actualiza automáticamente. Remueves un campo, y el compilador detecta cada referencia. Este es el tipo de cosas que previene categorías enteras de bugs.
Los template literal types te permiten manipular strings a nivel de tipos. Usan la misma sintaxis de backticks que los template literals de JavaScript, pero para tipos.
type Greeting = `Hello, ${string}`;
const a: Greeting = "Hello, world"; // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world"; // Error!TypeScript también proporciona tipos integrados de manipulación de strings:
type Upper = Uppercase<"hello">; // "HELLO"
type Lower = Lowercase<"HELLO">; // "hello"
type Cap = Capitalize<"hello">; // "Hello"
type Uncap = Uncapitalize<"Hello">; // "hello"Aquí es donde los template literal types realmente brillan. Este es un patrón que uso para sistemas de eventos con tipado seguro:
type EventNames<T extends string> = `${T}Changed` | `${T}Deleted` | `${T}Created`;
type ModelEvents = EventNames<"user" | "order" | "product">;
// "userChanged" | "userDeleted" | "userCreated"
// | "orderChanged" | "orderDeleted" | "orderCreated"
// | "productChanged" | "productDeleted" | "productCreated"Nueve eventos a partir de dos parámetros de tipo. Y todos son type-safe.
Los template literal types pueden forzar patrones CSS a nivel de tipos:
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
function setWidth(value: CSSValue): void {
// implementation
}
setWidth("100px"); // OK
setWidth("2.5rem"); // OK
setWidth("50%"); // OK
// setWidth("100"); // Error: not a valid CSS value
// setWidth("big"); // ErrorPuedes usar template literal types con infer para parsear strings:
type ParseRoute<T extends string> = T extends `${infer _Start}:${infer Param}/${infer Rest}`
? Param | ParseRoute<`/${Rest}`>
: T extends `${infer _Start}:${infer Param}`
? Param
: never;
type RouteParams = ParseRoute<"/users/:userId/posts/:postId">;
// Result: "userId" | "postId"Esta es la base de cómo funcionan las librerías de routing con tipado seguro como tRPC. El string /users/:userId/posts/:postId se parsea a nivel de tipos para extraer los nombres de los parámetros.
Aquí es donde las cosas se vuelven realmente poderosas:
type EventHandlers<T extends Record<string, unknown>> = {
[K in keyof T as `on${Capitalize<string & K>}Change`]: (newValue: T[K], oldValue: T[K]) => void;
};
interface ThemeConfig {
color: string;
fontSize: number;
darkMode: boolean;
}
type ThemeHandlers = EventHandlers<ThemeConfig>;
// {
// onColorChange: (newValue: string, oldValue: string) => void;
// onFontSizeChange: (newValue: number, oldValue: number) => void;
// onDarkModeChange: (newValue: boolean, oldValue: boolean) => void;
// }A partir de una simple interfaz de configuración, obtienes event handlers completamente tipados con los tipos de parámetros correctos. Este es el tipo de código a nivel de tipos que realmente ahorra tiempo de desarrollo.
Aquí hay un patrón más avanzado — generar todas las posibles rutas con notación de punto a través de un objeto anidado:
type DotPaths<T, Prefix extends string = ""> = T extends object
? {
[K in keyof T & string]: T[K] extends object
? `${Prefix}${K}` | DotPaths<T[K], `${Prefix}${K}.`>
: `${Prefix}${K}`;
}[keyof T & string]
: never;
interface Config {
database: {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
};
server: {
port: number;
};
}
type ConfigPaths = DotPaths<Config>;
// "database" | "database.host" | "database.port"
// | "database.credentials" | "database.credentials.username"
// | "database.credentials.password" | "server" | "server.port"Ahora puedes escribir una función getConfig que autocomplete cada ruta posible y retorne el tipo correcto para cada una:
type GetValueByPath<T, P extends string> = P extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? GetValueByPath<T[Key], Rest>
: never
: P extends keyof T
? T[P]
: never;
function getConfig<P extends ConfigPaths>(path: P): GetValueByPath<Config, P> {
// implementation
const keys = path.split(".");
let result: unknown = config;
for (const key of keys) {
result = (result as Record<string, unknown>)[key];
}
return result as GetValueByPath<Config, P>;
}
const host = getConfig("database.host"); // type: string
const port = getConfig("database.port"); // type: number
const username = getConfig("database.credentials.username"); // type: stringAutocompletado completo, seguridad de tipos total, cero overhead en runtime por los tipos.
TypeScript soporta definiciones de tipos recursivas, lo que te permite manejar estructuras anidadas de profundidad arbitraria.
El Partial<T> integrado solo hace opcionales las propiedades del primer nivel. Para objetos anidados, necesitas recursión:
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object
? T[K] extends Array<infer U>
? Array<DeepPartial<U>>
: DeepPartial<T[K]>
: T[K];
};
interface Settings {
theme: {
colors: {
primary: string;
secondary: string;
};
fonts: {
heading: string;
body: string;
};
};
notifications: {
email: boolean;
push: boolean;
};
}
type PartialSettings = DeepPartial<Settings>;
// Every nested property is now optionalObserva la verificación de arrays: sin ella, los arrays serían tratados como objetos y sus índices numéricos se volverían opcionales, que no es lo que quieres.
Mismo patrón, diferente modificador:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: T[K] extends Array<infer U>
? ReadonlyArray<DeepReadonly<U>>
: DeepReadonly<T[K]>
: T[K];
};La verificación de Function es importante — sin ella, las propiedades de tipo función tendrían readonly aplicado a su estructura interna, lo cual no tiene sentido.
Aquí hay un tipo recursivo clásico — representando cualquier valor JSON válido:
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonArray | JsonObject;
function parseJson(input: string): JsonValue {
return JSON.parse(input);
}
function isJsonObject(value: JsonValue): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}Esto es mucho mejor que usar any para el parseo de JSON. Le dice al consumidor "obtendrás algo de vuelta, pero necesitas hacer narrowing antes de usarlo". Lo cual es exactamente la verdad.
Un ejemplo práctico — tipando una estructura de menú de navegación:
interface MenuItem {
label: string;
href: string;
icon?: string;
children?: MenuItem[];
}
type Menu = MenuItem[];
const navigation: Menu = [
{
label: "Products",
href: "/products",
children: [
{
label: "Software",
href: "/products/software",
children: [
{ label: "IDE", href: "/products/software/ide" },
{ label: "CLI Tools", href: "/products/software/cli" },
],
},
{ label: "Hardware", href: "/products/hardware" },
],
},
{ label: "About", href: "/about" },
];La propiedad recursiva children te da anidamiento infinito con seguridad de tipos completa.
Déjame compartir algunos patrones que realmente he usado en sistemas de producción.
El patrón Builder se vuelve significativamente más útil cuando el sistema de tipos rastrea lo que se ha configurado:
type RequiredKeys = "host" | "port" | "database";
type BuilderState = {
[K in RequiredKeys]?: true;
};
class DatabaseConfigBuilder<State extends BuilderState = {}> {
private config: Partial<{
host: string;
port: number;
database: string;
ssl: boolean;
poolSize: number;
}> = {};
setHost(host: string): DatabaseConfigBuilder<State & { host: true }> {
this.config.host = host;
return this as unknown as DatabaseConfigBuilder<State & { host: true }>;
}
setPort(port: number): DatabaseConfigBuilder<State & { port: true }> {
this.config.port = port;
return this as unknown as DatabaseConfigBuilder<State & { port: true }>;
}
setDatabase(db: string): DatabaseConfigBuilder<State & { database: true }> {
this.config.database = db;
return this as unknown as DatabaseConfigBuilder<State & { database: true }>;
}
setSsl(ssl: boolean): DatabaseConfigBuilder<State> {
this.config.ssl = ssl;
return this as unknown as DatabaseConfigBuilder<State>;
}
setPoolSize(size: number): DatabaseConfigBuilder<State> {
this.config.poolSize = size;
return this as unknown as DatabaseConfigBuilder<State>;
}
build(
this: DatabaseConfigBuilder<{ host: true; port: true; database: true }>,
): Required<Pick<typeof this.config, "host" | "port" | "database">> &
Partial<Pick<typeof this.config, "ssl" | "poolSize">> {
return this.config as ReturnType<typeof this.build>;
}
}
// This compiles:
const config = new DatabaseConfigBuilder().setHost("localhost").setPort(5432).setDatabase("myapp").setSsl(true).build();
// This fails at compile time — missing .setDatabase():
// const bad = new DatabaseConfigBuilder()
// .setHost("localhost")
// .setPort(5432)
// .build(); // Error: 'database' is missingEl método build() solo se puede llamar cuando todos los campos requeridos han sido configurados. Esto se detecta en tiempo de compilación, no en runtime.
Aquí hay un event emitter donde los tipos del payload son forzados:
type EventMap = Record<string, unknown>;
class TypedEventEmitter<Events extends EventMap> {
private listeners: {
[K in keyof Events]?: Array<(payload: Events[K]) => void>;
} = {};
on<K extends keyof Events>(event: K, listener: (payload: Events[K]) => void): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
this.listeners[event]?.forEach((listener) => listener(payload));
}
off<K extends keyof Events>(event: K, listener: (payload: Events[K]) => void): void {
const handlers = this.listeners[event];
if (handlers) {
this.listeners[event] = handlers.filter((h) => h !== listener) as typeof handlers;
}
}
}
// Usage
interface AppEvents {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"notification:new": { message: string; level: "info" | "warning" | "error" };
}
const emitter = new TypedEventEmitter<AppEvents>();
// Fully typed — IDE autocompletes event names and payload shapes
emitter.on("user:login", (payload) => {
console.log(payload.userId); // string
console.log(payload.timestamp); // number
});
// Type error: payload doesn't match
// emitter.emit("user:login", { userId: "123" });
// Error: missing 'timestamp'
// Type error: event doesn't exist
// emitter.on("user:signup", () => {});
// Error: "user:signup" is not in AppEventsHe usado este patrón en tres proyectos de producción diferentes. Detecta categorías enteras de bugs relacionados con eventos en tiempo de compilación.
Las uniones discriminadas son probablemente el patrón individual más útil en TypeScript. Combinadas con verificación exhaustiva, garantizan que manejes cada caso:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
// This line ensures exhaustive checking
const _exhaustive: never = shape;
return _exhaustive;
}
}Si alguien agrega una nueva variante de forma (digamos "pentagon"), esta función fallará al compilar porque never no puede recibir un valor. El compilador te obliga a manejar cada caso.
Yo llevo esto más allá con una función auxiliar:
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
assertNever(shape, `Unknown shape kind: ${(shape as Shape).kind}`);
}
}Las uniones discriminadas también modelan máquinas de estado de manera hermosa:
type ConnectionState =
| { status: "disconnected" }
| { status: "connecting"; attempt: number }
| { status: "connected"; socket: WebSocket; connectedAt: Date }
| { status: "error"; error: Error; lastAttempt: Date };
type ConnectionAction =
| { type: "CONNECT" }
| { type: "CONNECTED"; socket: WebSocket }
| { type: "DISCONNECT" }
| { type: "ERROR"; error: Error }
| { type: "RETRY" };
function connectionReducer(state: ConnectionState, action: ConnectionAction): ConnectionState {
switch (state.status) {
case "disconnected":
if (action.type === "CONNECT") {
return { status: "connecting", attempt: 1 };
}
return state;
case "connecting":
if (action.type === "CONNECTED") {
return {
status: "connected",
socket: action.socket,
connectedAt: new Date(),
};
}
if (action.type === "ERROR") {
return {
status: "error",
error: action.error,
lastAttempt: new Date(),
};
}
return state;
case "connected":
if (action.type === "DISCONNECT") {
state.socket.close();
return { status: "disconnected" };
}
if (action.type === "ERROR") {
return {
status: "error",
error: action.error,
lastAttempt: new Date(),
};
}
return state;
case "error":
if (action.type === "RETRY") {
return { status: "connecting", attempt: 1 };
}
if (action.type === "DISCONNECT") {
return { status: "disconnected" };
}
return state;
default:
assertNever(state, "Unknown connection state");
}
}Cada estado solo tiene las propiedades que tienen sentido para ese estado. No puedes acceder a socket cuando estás desconectado. No puedes acceder a error cuando estás conectado. El sistema de tipos fuerza las restricciones de la máquina de estados.
Un patrón más que encuentro esencial — usar branded types para prevenir la mezcla de valores que comparten el mismo tipo subyacente:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
function createUserId(id: string): UserId {
return id as UserId;
}
function createOrderId(id: string): OrderId {
return id as OrderId;
}
function getUser(id: UserId): Promise<User> {
// ...
}
function getOrder(id: OrderId): Promise<Order> {
// ...
}
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
getUser(userId); // OK
// getUser(orderId); // Error! OrderId is not assignable to UserIdTanto UserId como OrderId son strings en runtime. Pero en tiempo de compilación, son tipos distintos. Literalmente no puedes pasar un ID de orden donde se espera un ID de usuario. Esto ha detectado bugs reales en cada proyecto donde lo he usado.
Los tipos avanzados son poderosos, pero vienen con trampas. Esto es lo que he aprendido por las malas.
TypeScript tiene un límite de profundidad de recursión para tipos (actualmente alrededor de 50 niveles, aunque varía). Si te pasas, obtendrás el temido error "Type instantiation is excessively deep and possibly infinite".
// This will hit the recursion limit for deeply nested objects
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
// Fix: Add a depth counter
type DeepPartialSafe<T, Depth extends number[] = []> = Depth["length"] extends 10
? T
: {
[K in keyof T]?: T[K] extends object ? DeepPartialSafe<T[K], [...Depth, 0]> : T[K];
};El truco del contador de profundidad usa una tupla que crece con cada paso recursivo. Cuando llega a tu límite, la recursión se detiene.
Los tipos complejos pueden ralentizar significativamente tu editor y los tiempos de compilación. He visto proyectos donde un solo tipo excesivamente ingenioso agregó 3 segundos al ciclo de retroalimentación de cada pulsación de tecla.
Señales de advertencia:
tsc --noEmit tarda notablemente más después de agregar un tipo// This is too clever — it generates a combinatorial explosion
type AllCombinations<T extends string[]> = T extends [infer First extends string, ...infer Rest extends string[]]
? `${First}${AllCombinations<Rest>}` | AllCombinations<Rest>
: "";
// Don't do this with more than 5-6 elementsEsta podría ser la sección más importante. Los tipos avanzados deben usarse cuando previenen bugs o mejoran la experiencia del desarrollador. No deben usarse para lucirte.
No los uses cuando:
Record<string, unknown> sería suficienteSí, úsalos cuando:
// Over-engineered — don't do this for a simple config
type Config = DeepReadonly<DeepRequired<Merge<DefaultConfig, Partial<UserConfig>>>>;
// Just do this
interface Config {
readonly host: string;
readonly port: number;
readonly debug: boolean;
}Mi regla general: si explicar el tipo toma más tiempo que explicar el bug que previene, simplifícalo.
Cuando un tipo complejo no funciona, uso este helper para "ver" lo que TypeScript ha resuelto:
// Expands a type for inspection in IDE hover tooltips
type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
// Use it to debug
type Problematic = SomeComplexType<Input>;
type Debug = Prettify<Problematic>;
// Now hover over Debug in your IDE to see the resolved typeEl truco de & {} fuerza a TypeScript a evaluar el tipo ansiosamente en lugar de mostrarte el alias del tipo. Me ha ahorrado horas de confusión.
Otra técnica — aislar y probar incrementalmente:
// Instead of debugging this all at once:
type Final = StepThree<StepTwo<StepOne<Input>>>;
// Break it apart:
type AfterStep1 = StepOne<Input>; // hover to check
type AfterStep2 = StepTwo<AfterStep1>; // hover to check
type AfterStep3 = StepThree<AfterStep2>; // hover to checkT extends U ? X : Y) son if/else para tipos. Cuidado con el comportamiento distributivo con unions.infer es pattern matching — úsalo para extraer tipos de estructuras como firmas de funciones, promises y arrays.{ [K in keyof T]: ... }) iteran sobre propiedades. Combínalos con as para remapeo y filtrado de claves.El sistema de tipos de TypeScript es Turing completo, lo que significa que puedes hacer casi cualquier cosa con él. El arte está en saber cuándo deberías.