Saltar al contenido principal
Volver al blog

Patrones avanzados de TypeScript para aplicaciones React

Ray MartínRay Martín
10 min de lectura
Patrones avanzados de TypeScript para aplicaciones React

Por que importa el TypeScript avanzado en React

TypeScript no se trata solo de anadir : string a tus variables. Cuando se usa a su maximo potencial, TypeScript se convierte en una herramienta de diseno que codifica logica de negocio, previene categorias enteras de bugs y hace que la refactorizacion sea segura. En un codebase de React, los patrones avanzados de TypeScript transforman tus componentes de elementos UI vagamente tipados en bloques de construccion precisamente modelados donde los estados invalidos son literalmente irrepresentables.

Esta guia cubre los patrones que separan a un usuario basico de TypeScript de un usuario avanzado: generics para componentes reutilizables, uniones discriminadas para gestion de estado, template literal types para routing type-safe, branded types para modelado de dominio, y componentes polimorficos que adaptan sus tipos segun los elementos renderizados.

Cada patron incluye ejemplos practicos de React que puedes aplicar directamente en tus proyectos Next.js. Al final, tendras un toolkit de tecnicas de tipado avanzadas que hacen tu codebase mas expresivo, seguro y facil de mantener.

Generics en componentes React

Los componentes genericos te permiten construir elementos de UI reutilizables que preservan la informacion de tipo sobre los datos que manejan. En lugar de usar any o una union de tipos posibles, los generics dejan que el consumidor decida el tipo de datos mientras mantiene la seguridad de tipos completa.

Componente List generico

typescript
// Una lista generica que funciona con cualquier tipo de dato
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string;
  emptyMessage?: string;
}

function List<T>({
  items,
  renderItem,
  keyExtractor,
  emptyMessage = "No se encontraron elementos",
}: ListProps<T>) {
  if (items.length === 0) {
    return <p className="text-gray-500">{emptyMessage}</p>;
  }

  return (
    <ul className="space-y-2">
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>{renderItem(item, index)}</li>
      ))}
    </ul>
  );
}

// Uso: TypeScript infiere T como User del prop items
interface User {
  id: string;
  name: string;
  email: string;
}

<List
  items={users}
  keyExtractor={(user) => user.id}       // user esta tipado como User
  renderItem={(user) => <span>{user.name}</span>} // user esta tipado como User
/>

Componente Select generico

typescript
interface SelectProps<T> {
  options: T[];
  value: T | null;
  onChange: (value: T) => void;
  getLabel: (option: T) => string;
  getValue: (option: T) => string;
  placeholder?: string;
}

function Select<T>({
  options,
  value,
  onChange,
  getLabel,
  getValue,
  placeholder = "Selecciona una opcion",
}: SelectProps<T>) {
  return (
    <select
      value={value ? getValue(value) : ""}
      onChange={(e) => {
        const selected = options.find(
          (opt) => getValue(opt) === e.target.value
        );
        if (selected) onChange(selected);
      }}
    >
      <option value="" disabled>{placeholder}</option>
      {options.map((option) => (
        <option key={getValue(option)} value={getValue(option)}>
          {getLabel(option)}
        </option>
      ))}
    </select>
  );
}

// Uso con un tipo Country
interface Country {
  code: string;
  name: string;
  population: number;
}

<Select<Country>
  options={countries}
  value={selectedCountry}
  onChange={setSelectedCountry}
  getLabel={(c) => c.name}         // c esta tipado como Country
  getValue={(c) => c.code}         // c esta tipado como Country
/>

Componente Table generico

typescript
interface Column<T> {
  key: keyof T & string;
  header: string;
  render?: (value: T[keyof T], item: T) => React.ReactNode;
  sortable?: boolean;
}

interface TableProps<T> {
  data: T[];
  columns: Column<T>[];
  onRowClick?: (item: T) => void;
}

function Table<T extends Record<string, unknown>>({
  data,
  columns,
  onRowClick,
}: TableProps<T>) {
  return (
    <table className="w-full border-collapse">
      <thead>
        <tr>
          {columns.map((col) => (
            <th key={col.key} className="border-b p-3 text-left font-semibold">
              {col.header}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {data.map((item, rowIndex) => (
          <tr
            key={rowIndex}
            onClick={() => onRowClick?.(item)}
            className={onRowClick ? "cursor-pointer hover:bg-gray-50" : ""}
          >
            {columns.map((col) => (
              <td key={col.key} className="border-b p-3">
                {col.render
                  ? col.render(item[col.key], item)
                  : String(item[col.key])}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

// Uso: las keys de las columnas se validan contra el tipo Product
interface Product {
  id: string;
  name: string;
  price: number;
  inStock: boolean;
}

<Table<Product>
  data={products}
  columns={[
    { key: "name", header: "Nombre del producto" },
    { key: "price", header: "Precio", render: (v) => `${v}` },
    { key: "inStock", header: "Disponible", render: (v) => (v ? "Si" : "No") },
    // { key: "invalido", header: "X" } // Error de TypeScript: "invalido" no esta en Product
  ]}
  onRowClick={(product) => console.log(product.name)} // product esta tipado como Product
/>

Utility Types en la practica

Los utility types integrados de TypeScript son herramientas poderosas para transformar tipos existentes sin duplicar definiciones. Estos son los mas utiles para desarrollo con React:

typescript
interface UserProfile {
  id: string;
  email: string;
  name: string;
  avatar: string;
  bio: string;
  role: "admin" | "editor" | "viewer";
  createdAt: Date;
  updatedAt: Date;
}

// Pick: Seleccionar propiedades especificas
type UserCard = Pick<UserProfile, "id" | "name" | "avatar">;
// Resultado: { id: string; name: string; avatar: string }

// Omit: Excluir propiedades especificas
type CreateUserInput = Omit<UserProfile, "id" | "createdAt" | "updatedAt">;
// Resultado: { email: string; name: string; avatar: string; bio: string; role: ... }

// Partial: Hacer todas las propiedades opcionales
type UpdateUserInput = Partial<Omit<UserProfile, "id">>;
// Resultado: { email?: string; name?: string; avatar?: string; ... }

// Required: Hacer todas las propiedades obligatorias
type CompleteProfile = Required<UserProfile>;

// Record: Crear un tipo con keys y valores especificos
type RolePermissions = Record<UserProfile["role"], string[]>;
// Resultado: { admin: string[]; editor: string[]; viewer: string[] }

const permissions: RolePermissions = {
  admin: ["read", "write", "delete", "manage"],
  editor: ["read", "write"],
  viewer: ["read"],
};

// Readonly: Hacer todas las propiedades inmutables
type FrozenUser = Readonly<UserProfile>;
// Todas las propiedades se vuelven readonly: no se pueden reasignar

// Extract y Exclude para tipos union
type AllRoles = "admin" | "editor" | "viewer" | "superadmin";
type StandardRoles = Exclude<AllRoles, "superadmin">;  // "admin" | "editor" | "viewer"
type AdminRoles = Extract<AllRoles, "admin" | "superadmin">; // "admin" | "superadmin"

Uniones discriminadas para gestion de estado

Las uniones discriminadas son uno de los patrones mas poderosos de TypeScript para aplicaciones React. Te permiten modelar estados donde ciertas propiedades solo existen bajo ciertas condiciones, haciendo que los estados imposibles sean irrepresentables.

typescript
// Modelar estados de datos asincronos con uniones discriminadas
type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string };

function useAsync<T>() {
  const [state, setState] = useState<AsyncState<T>>({ status: "idle" });

  const execute = async (promise: Promise<T>) => {
    setState({ status: "loading" });
    try {
      const data = await promise;
      setState({ status: "success", data });
    } catch (error) {
      setState({
        status: "error",
        error: error instanceof Error ? error.message : "Error desconocido",
      });
    }
  };

  return { state, execute };
}

// Componente que maneja exhaustivamente todos los estados
function UserProfile() {
  const { state, execute } = useAsync<User>();

  useEffect(() => {
    execute(fetchUser());
  }, []);

  switch (state.status) {
    case "idle":
      return <p>Listo para cargar</p>;
    case "loading":
      return <Spinner />;
    case "success":
      return <div>{state.data.name}</div>;  // data esta garantizado que existe
    case "error":
      return <p className="text-red-500">{state.error}</p>; // error esta garantizado
  }
}

Otro caso de uso comun es modelar estados de modales o dialogos:

typescript
type ModalState =
  | { type: "closed" }
  | { type: "confirm"; title: string; message: string; onConfirm: () => void }
  | { type: "edit"; itemId: string; initialData: FormData }
  | { type: "preview"; content: string };

function ModalManager({ state }: { state: ModalState }) {
  switch (state.type) {
    case "closed":
      return null;
    case "confirm":
      return (
        <ConfirmDialog
          title={state.title}
          message={state.message}
          onConfirm={state.onConfirm}
        />
      );
    case "edit":
      return <EditForm itemId={state.itemId} initialData={state.initialData} />;
    case "preview":
      return <PreviewPanel content={state.content} />;
  }
}

Inferencia de tipos con as const y satisfies

La asercion as const y el operador satisfies son dos de las funcionalidades mas infrautilizadas de TypeScript. Juntas, te permiten definir datos con tipos literales precisos mientras validas contra un tipo mas amplio.

typescript
// as const estrecha los tipos a sus valores literales
const routes = {
  home: "/",
  blog: "/blog",
  about: "/about",
  contact: "/contact",
} as const;

// El tipo es: { readonly home: "/"; readonly blog: "/blog"; ... }
// Sin as const, seria: { home: string; blog: string; ... }

type Route = (typeof routes)[keyof typeof routes];
// Route = "/" | "/blog" | "/about" | "/contact"

// satisfies valida la forma sin ampliar el tipo
interface ThemeConfig {
  colors: Record<string, string>;
  spacing: Record<string, string>;
}

const theme = {
  colors: {
    primary: "#0c4a6e",
    secondary: "#082f49",
    accent: "#f59e0b",
  },
  spacing: {
    sm: "0.5rem",
    md: "1rem",
    lg: "2rem",
  },
} as const satisfies ThemeConfig;

// theme.colors.primary esta tipado como "#0c4a6e" (literal), no string
// Pero TypeScript verifico que cumple con la forma de ThemeConfig

// Ejemplo practico en React: configuracion de navegacion
interface NavItem {
  label: string;
  href: string;
  icon?: string;
}

const navigation = [
  { label: "Inicio", href: "/", icon: "home" },
  { label: "Blog", href: "/blog", icon: "book" },
  { label: "Acerca de", href: "/about" },
] as const satisfies readonly NavItem[];

// Cada item retiene tipos literales para label y href
// TypeScript tambien verifico que cada item tiene los campos label y href requeridos

Tipos condicionales y tipos mapeados

Los tipos condicionales te permiten crear tipos que dependen de otros tipos, similar a expresiones ternarias a nivel de valores. Los tipos mapeados te permiten transformar cada propiedad de un tipo sistematicamente.

typescript
// Tipo condicional: extraer el tipo de respuesta de una funcion API
type ApiResponse<T> = T extends (...args: unknown[]) => Promise<infer R> ? R : never;

async function fetchUsers(): Promise<User[]> {
  const res = await fetch("/api/users");
  return res.json();
}

type Users = ApiResponse<typeof fetchUsers>; // User[]

// Tipo mapeado: hacer todas las propiedades opcionales y anulables
type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};

type NullableUser = Nullable<User>;
// { id: string | null; name: string | null; email: string | null }

// Tipo mapeado con remapeo de keys
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type UserGetters = Getters<User>;
// { getId: () => string; getName: () => string; getEmail: () => string }

// Ejemplo practico: tracking de campos modificados en formularios
type DirtyFields<T> = {
  [K in keyof T]?: boolean;
};

interface FormState<T> {
  values: T;
  initialValues: T;
  dirtyFields: DirtyFields<T>;
  isDirty: boolean;
}

// Deep partial para objetos anidados
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

interface Settings {
  theme: {
    mode: "light" | "dark";
    colors: { primary: string; secondary: string };
  };
  notifications: {
    email: boolean;
    push: boolean;
  };
}

// Solo actualizar campos anidados especificos
function updateSettings(patch: DeepPartial<Settings>) {
  // fusionar patch con la configuracion actual
}

Template Literal Types

Los template literal types te permiten construir tipos de string a partir de otros tipos, habilitando patrones de string type-safe para APIs, CSS, routing y mas.

typescript
// Generador de utilidades CSS type-safe
type Size = "sm" | "md" | "lg" | "xl";
type Direction = "t" | "r" | "b" | "l" | "x" | "y";
type PaddingClass = `p${Direction}-${Size}`;
// "pt-sm" | "pt-md" | "pt-lg" | ... | "py-xl" (24 combinaciones)

// Nombres de event handlers type-safe
type DomEvent = "click" | "focus" | "blur" | "change";
type EventHandler = `on${Capitalize<DomEvent>}`;
// "onClick" | "onFocus" | "onBlur" | "onChange"

// Rutas API type-safe
type Resource = "users" | "posts" | "comments";
type Method = "get" | "create" | "update" | "delete";
type ApiEndpoint = `/api/${Resource}`;
type ApiAction = `${Method}${Capitalize<Resource>}`;
// "getUsers" | "createUsers" | "updatePosts" | "deleteComments" | ...

// Ejemplo practico: claves de traduccion type-safe
type Namespace = "hero" | "about" | "contact";
type HeroKeys = "title" | "subtitle" | "cta";
type AboutKeys = "heading" | "description";
type ContactKeys = "form_title" | "submit";

type TranslationKey =
  | `hero.${HeroKeys}`
  | `about.${AboutKeys}`
  | `contact.${ContactKeys}`;

function translate(key: TranslationKey): string {
  // implementacion
  return "";
}

translate("hero.title");      // valido
translate("contact.submit");  // valido
// translate("hero.invalido"); // Error de TypeScript

La palabra clave infer

La palabra clave infer te permite extraer tipos de dentro de otros tipos en expresiones de tipos condicionales. Es como pattern matching para tipos.

typescript
// Extraer el tipo resuelto de una Promise
type Unwrap<T> = T extends Promise<infer U> ? U : T;

type A = Unwrap<Promise<string>>;  // string
type B = Unwrap<Promise<User[]>>;  // User[]
type C = Unwrap<number>;           // number (no es una Promise, se devuelve tal cual)

// Extraer el tipo de props de un componente React
type ComponentProps<T> = T extends React.ComponentType<infer P> ? P : never;

type ButtonProps = ComponentProps<typeof Button>;
// Extrae la interfaz de props del componente Button

// Extraer el tipo de retorno de la primera funcion en una tupla
type FirstReturn<T> = T extends [(...args: unknown[]) => infer R, ...unknown[]]
  ? R
  : never;

// Extraer el tipo de elemento de un array
type ElementType<T> = T extends (infer E)[] ? E : never;

type UserElement = ElementType<User[]>;  // User

// Ejemplo practico: extraer tipos de retorno de Server Actions
type ServerActionReturn<T extends (...args: unknown[]) => Promise<unknown>> =
  T extends (...args: unknown[]) => Promise<infer R> ? R : never;

async function createPost(data: FormData): Promise<{ id: string; slug: string }> {
  // implementacion
  return { id: "1", slug: "test" };
}

type CreatePostResult = ServerActionReturn<typeof createPost>;
// { id: string; slug: string }

Branded Types para IDs type-safe

En la mayoria de aplicaciones, los IDs son simples strings. Esto significa que puedes pasar accidentalmente un ID de usuario donde se espera un ID de post, y TypeScript no se quejara. Los branded types (tambien llamados tipos nominales) resuelven esto creando tipos distintos que son estructuralmente identicos pero nominalmente diferentes.

typescript
// Crear un branded type usando interseccion con un symbol unico
declare const brand: unique symbol;

type Brand<T, TBrand extends string> = T & { readonly [brand]: TBrand };

// Definir tipos de ID especificos
type UserId = Brand<string, "UserId">;
type PostId = Brand<string, "PostId">;
type CategoryId = Brand<string, "CategoryId">;

// Funciones constructoras para crear valores branded
function userId(id: string): UserId {
  return id as UserId;
}

function postId(id: string): PostId {
  return id as PostId;
}

// Ahora TypeScript previene mezclar IDs
function fetchUser(id: UserId): Promise<User> {
  return fetch(`/api/users/${id}`).then((r) => r.json());
}

function fetchPost(id: PostId): Promise<Post> {
  return fetch(`/api/posts/${id}`).then((r) => r.json());
}

const uid = userId("user_abc123");
const pid = postId("post_xyz789");

fetchUser(uid);  // OK
fetchPost(pid);  // OK
// fetchUser(pid); // Error de TypeScript: PostId no es asignable a UserId
// fetchPost(uid); // Error de TypeScript: UserId no es asignable a PostId

// Tambien puedes brandear otros primitivos
type Dollars = Brand<number, "Dollars">;
type Euros = Brand<number, "Euros">;

function dollars(amount: number): Dollars {
  return amount as Dollars;
}

function euros(amount: number): Euros {
  return amount as Euros;
}

function chargeInDollars(amount: Dollars) {
  // procesar pago
}

chargeInDollars(dollars(29.99));  // OK
// chargeInDollars(euros(29.99)); // Error de TypeScript

Componentes polimorficos con ComponentPropsWithoutRef

Los componentes polimorficos pueden renderizarse como diferentes elementos HTML mientras mantienen los tipos de props correctos para el elemento elegido. Este patron se usa extensamente en librerias de componentes como Radix UI y Headless UI.

typescript
import { ComponentPropsWithoutRef, ElementType, ReactNode } from "react";

// El patron del prop 'as'
type PolymorphicProps<E extends ElementType> = {
  as?: E;
  children?: ReactNode;
} & Omit<ComponentPropsWithoutRef<E>, "as" | "children">;

function Text<E extends ElementType = "span">({
  as,
  children,
  ...props
}: PolymorphicProps<E>) {
  const Component = as || "span";
  return <Component {...props}>{children}</Component>;
}

// Uso: los props se tipan segun el elemento 'as'
<Text>Span por defecto</Text>
<Text as="p" className="text-lg">Parrafo</Text>
<Text as="a" href="/about" target="_blank">Enlace</Text>
// <Text as="a" href={123} /> // Error de TypeScript: href debe ser string
// <Text as="div" href="/test" /> // Error de TypeScript: div no tiene prop href

// Ejemplo mas practico: componente Box
type BoxProps<E extends ElementType> = PolymorphicProps<E> & {
  padding?: "sm" | "md" | "lg";
  rounded?: boolean;
};

function Box<E extends ElementType = "div">({
  as,
  padding = "md",
  rounded = false,
  className,
  children,
  ...props
}: BoxProps<E>) {
  const Component = as || "div";
  const paddingClasses = { sm: "p-2", md: "p-4", lg: "p-8" };

  return (
    <Component
      className={cn(
        paddingClasses[padding],
        rounded && "rounded-lg",
        className
      )}
      {...props}
    >
      {children}
    </Component>
  );
}

// Se renderiza como <section> con props especificos de section disponibles
<Box as="section" padding="lg" rounded aria-labelledby="heading">
  Contenido
</Box>

Inferencia de esquemas con Zod

Zod te permite definir esquemas de validacion en tiempo de ejecucion y extraer sus tipos TypeScript usando z.infer. Esto elimina la necesidad de mantener definiciones de tipos y reglas de validacion separadas: el esquema es la unica fuente de verdad.

typescript
import { z } from "zod";

// Definir el esquema una sola vez
const UserSchema = z.object({
  id: z.string().cuid(),
  email: z.string().email("Direccion de email invalida"),
  name: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
  role: z.enum(["admin", "editor", "viewer"]),
  profile: z
    .object({
      bio: z.string().max(500).optional(),
      website: z.string().url().optional(),
      social: z.object({
        twitter: z.string().optional(),
        github: z.string().optional(),
      }),
    })
    .optional(),
  createdAt: z.date(),
});

// Inferir el tipo TypeScript del esquema
type User = z.infer<typeof UserSchema>;
// {
//   id: string;
//   email: string;
//   name: string;
//   role: "admin" | "editor" | "viewer";
//   profile?: {
//     bio?: string;
//     website?: string;
//     social: { twitter?: string; github?: string };
//   };
//   createdAt: Date;
// }

// Crear esquemas de input/output a partir del esquema base
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
type CreateUserInput = z.infer<typeof CreateUserSchema>;

const UpdateUserSchema = UserSchema.partial().required({ id: true });
type UpdateUserInput = z.infer<typeof UpdateUserSchema>;

// Usar en un Server Action con seguridad de tipos completa
async function createUser(formData: FormData) {
  const rawData = Object.fromEntries(formData);
  const validated = CreateUserSchema.parse(rawData);
  // validated esta tipado como CreateUserInput con todas las validaciones aplicadas
  return prisma.user.create({ data: validated });
}

// Usar con React Hook Form
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

const ContactSchema = z.object({
  name: z.string().min(1, "El nombre es obligatorio"),
  email: z.string().email("Email invalido"),
  message: z.string().min(10, "El mensaje debe tener al menos 10 caracteres"),
});

type ContactForm = z.infer<typeof ContactSchema>;

function ContactForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<ContactForm>({
    resolver: zodResolver(ContactSchema),
  });

  // register("name") es type-safe: solo "name" | "email" | "message" son validos
  // errors.name?.message esta tipado como string | undefined
}

Combinandolo todo

Aqui tienes un ejemplo practico que combina varios de estos patrones en un hook de data-fetching realista con seguridad de tipos completa:

typescript
import { z } from "zod";
import { useState, useCallback } from "react";

// Branded types para endpoints API
type ApiEndpoint = Brand<string, "ApiEndpoint">;
function endpoint(path: string): ApiEndpoint {
  return path as ApiEndpoint;
}

// Union discriminada para estado de la peticion
type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T; fetchedAt: Date }
  | { status: "error"; error: string; retryCount: number };

// Hook generico con inferencia de esquema Zod
function useApiQuery<TSchema extends z.ZodType>(
  url: ApiEndpoint,
  schema: TSchema
) {
  type TData = z.infer<TSchema>;
  const [state, setState] = useState<RequestState<TData>>({ status: "idle" });

  const fetch = useCallback(async () => {
    setState({ status: "loading" });
    try {
      const response = await globalThis.fetch(url);
      const json = await response.json();
      const data = schema.parse(json); // Validacion en runtime + inferencia de tipos
      setState({ status: "success", data, fetchedAt: new Date() });
    } catch (error) {
      setState({
        status: "error",
        error: error instanceof Error ? error.message : "Error desconocido",
        retryCount: 0,
      });
    }
  }, [url, schema]);

  return { state, fetch } as const;
}

// Uso
const PostListSchema = z.array(
  z.object({
    id: z.string(),
    title: z.string(),
    slug: z.string(),
  })
);

function BlogList() {
  const { state, fetch } = useApiQuery(
    endpoint("/api/posts"),
    PostListSchema
  );

  // state.data esta tipado como { id: string; title: string; slug: string }[]
  // TypeScript asegura el manejo exhaustivo de todos los estados
}

Dominando estos patrones avanzados de TypeScript, transformas tu codebase de React de uno que simplemente compila a uno que codifica los invariantes de tu aplicacion, previene categorias enteras de bugs y proporciona una experiencia de desarrollo excepcional a traves de autocompletado preciso y mensajes de error claros.

Compartir:

Artículos relacionados