TypeScript en React: Tipado Avanzado para Componentes Robustos

TypeScript en React: Tipado Avanzado para Componentes Robustos
Photo by Shahadat Rahman / Unsplash

Introducción

El desarrollo frontend moderno ha evolucionado significativamente, pero sigue enfrentándose a un desafío fundamental: la naturaleza dinámicamente tipada de JavaScript. En aplicaciones React de tamaño considerable, esta característica que inicialmente parece otorgar flexibilidad se convierte rápidamente en una fuente de bugs difíciles de detectar, refactorizaciones temerosas y documentación excesiva.

TypeScript emerge como una solución que proporciona seguridad de tipos sin sacrificar la expresividad de JavaScript. Para los proyectos React, esto no es simplemente una preferencia, sino una herramienta transformadora que cambia fundamentalmente cómo construimos, mantenemos y escalamos nuestras aplicaciones.

El problema de tipos en JavaScript y React

JavaScript fue diseñado como un lenguaje de scripting ligero, donde la flexibilidad de tipos era una ventaja. Sin embargo, en aplicaciones complejas como las que construimos hoy con React, esta flexibilidad puede volverse problemática:

// Un componente aparentemente simple
function UserProfile({ user, onUpdate }) {
  return (
    <div>
      <h2>{user.name}</h2>
      <button onClick={() => onUpdate(user.id)}>Actualizar</button>
    </div>
  );
}

¿Qué sucede si user es undefined? ¿O si onUpdate no es una función? ¿Qué propiedades exactamente debe tener el objeto user? Sin tipos, estos interrogantes se convierten en errores en tiempo de ejecución que los usuarios experimentan directamente.

Beneficios de TypeScript en proyectos React

La adopción de TypeScript en proyectos React ofrece ventajas que van más allá de la simple prevención de errores:

  • Documentación integrada: Los tipos actúan como documentación viva que nunca queda desactualizada
  • Autocompletado mejorado: Los editores pueden ofrecer sugerencias precisas basadas en los tipos
  • Refactorización segura: Cambiar la estructura de props o estados se vuelve mucho más seguro
  • Detección temprana de errores: Los problemas se identifican durante el desarrollo, no en producción
  • Mejor experiencia de desarrollo: El feedback inmediato reduce la necesidad de debugging extensivo

Evolución del soporte de TypeScript en el ecosistema React

El soporte de TypeScript en el ecosistema React ha madurado considerablemente:

  • 2016: Definiciones de tipos de la comunidad a través de DefinitelyTyped
  • 2018: Create React App añade soporte nativo para TypeScript
  • 2019: React Hooks lanzados con soporte completo para TypeScript
  • 2020: React 17 mejora la tipabilidad de eventos y elementos JSX
  • 2022: React 18 incluye tipos mejorados para las nuevas funcionalidades de concurrencia
  • 2023: Framework de metadatos para Server Components integrado con TypeScript

Esta evolución refleja un compromiso continuo con la integración de TypeScript, haciendo que sea cada vez más natural y poderoso trabajar con ambas tecnologías juntas.

Fundamentos de TypeScript en React

Antes de sumergirnos en técnicas avanzadas, es fundamental establecer una base sólida. Los fundamentos determinan qué tan efectivamente podemos aprovechar las características más potentes de TypeScript en nuestras aplicaciones React.

Configuración básica del proyecto

Un proyecto React con TypeScript puede configurarse de varias maneras. La opción más directa es utilizar Create React App con la plantilla de TypeScript:

npx create-react-app my-app --template typescript

Para proyectos Vite, que ofrecen una experiencia de desarrollo más rápida:

npm create vite@latest my-app -- --template react-ts

Una configuración básica de TypeScript para React normalmente incluye estos ajustes en el tsconfig.json:

{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": ["src"]
}
💡 Pro tip
Habilitar strict: true desde el inicio puede parecer intimidante, pero previene muchos errores sutiles y te brinda todos los beneficios de TypeScript. Es mucho más difícil habilitarlo posteriormente en un proyecto grande.

Tipado de props básicas

El tipado de props es el punto de partida para componentes React seguros. Hay dos enfoques principales:

Interfaces

interface ButtonProps {
  text: string;
  onClick: () => void;
  disabled?: boolean;
}

const Button: React.FC<ButtonProps> = ({ text, onClick, disabled = false }) => {
  return (
    <button onClick={onClick} disabled={disabled}>
      {text}
    </button>
  );
};

Types

type ButtonProps = {
  text: string;
  onClick: () => void;
  disabled?: boolean;
};

function Button({ text, onClick, disabled = false }: ButtonProps) {
  return (
    <button onClick={onClick} disabled={disabled}>
      {text}
    </button>
  );
}
🔍 Detalle técnico
Aunque interface y type son similares, interface es extensible y puede ser aumentado posteriormente, mientras que type puede utilizar uniones y es más flexible para tipos complejos. Para props de componentes simples, ambos funcionan bien.

JSX con TypeScript

TypeScript mejora significativamente la experiencia al trabajar con JSX, proporcionando verificación de tipos para elementos, atributos y eventos:

// TypeScript verifica que no falten props requeridas
<Button text="Enviar" onClick={() => console.log("Clicked")} />

// Error: Property 'text' is missing
<Button onClick={() => console.log("Clicked")} />

// TypeScript detecta tipos incompatibles
<Button text={42} onClick={() => console.log("Clicked")} />

Los eventos en JSX también son tipados correctamente:

function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
  console.log(event.target.value);
}

<input type="text" onChange={handleChange} />;

Tipado avanzado de componentes

Con los fundamentos establecidos, podemos explorar técnicas más sofisticadas para modelar componentes React complejos con TypeScript.

Tipado de children y render props

Los children son una parte fundamental de la composición en React. TypeScript nos permite especificar exactamente qué tipo de children esperamos:

// Children genéricos (cualquier contenido renderizable)
interface CardProps {
  children: React.ReactNode;
}

// Children restringidos a elementos específicos
interface ListProps {
  children: React.ReactElement<typeof ListItem> | React.ReactElement<typeof ListItem>[];
}

// Render props tipadas
interface DataRendererProps<T> {
  data: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
}

function DataRenderer<T>({ data, renderItem }: DataRendererProps<T>) {
  return <div>{data.map((item, index) => renderItem(item, index))}</div>;
}

// Uso
<DataRenderer 
  data={['a', 'b', 'c']} 
  renderItem={(item) => <span key={item}>{item}</span>} 
/>;

Componentes genéricos

Los componentes genéricos permiten crear abstracciones flexibles pero tipadas. Son especialmente útiles para componentes de UI reutilizables que trabajan con diferentes tipos de datos:

interface SelectProps<T> {
  items: T[];
  selectedItem: T;
  onSelect: (item: T) => void;
  getLabel: (item: T) => string;
  getValue: (item: T) => string | number;
}

function Select<T>({ 
  items, 
  selectedItem, 
  onSelect, 
  getLabel, 
  getValue 
}: SelectProps<T>) {
  return (
    <select 
      value={getValue(selectedItem).toString()} 
      onChange={(e) => {
        const selected = items.find(
          item => getValue(item).toString() === e.target.value
        );
        if (selected) onSelect(selected);
      }}
    >
      {items.map(item => (
        <option key={getValue(item).toString()} value={getValue(item).toString()}>
          {getLabel(item)}
        </option>
      ))}
    </select>
  );
}

// Uso con diferentes tipos
type User = { id: number; name: string; };
const users: User[] = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' }
];

<Select<User>
  items={users}
  selectedItem={users[0]}
  onSelect={(user) => console.log(`Selected ${user.name}`)}
  getLabel={(user) => user.name}
  getValue={(user) => user.id}
/>;

Discriminated unions para props complejas

Las uniones discriminadas son una técnica poderosa para modelar componentes con comportamientos condicionales:

type LoadingState = {
  status: 'loading';
};

type SuccessState<T> = {
  status: 'success';
  data: T;
};

type ErrorState = {
  status: 'error';
  error: string;
};

type AsyncState<T> = LoadingState | SuccessState<T> | ErrorState;

interface AsyncContentProps<T> {
  state: AsyncState<T>;
  renderData: (data: T) => React.ReactNode;
}

function AsyncContent<T>({ state, renderData }: AsyncContentProps<T>) {
  switch (state.status) {
    case 'loading':
      return <Spinner />;
    case 'success':
      return <>{renderData(state.data)}</>;
    case 'error':
      return <ErrorMessage message={state.error} />;
  }
}

// Uso
const userState: AsyncState<User> = { 
  status: 'success', 
  data: { id: 1, name: 'Alice' } 
};

<AsyncContent 
  state={userState} 
  renderData={(user) => <UserProfile user={user} />} 
/>;
⚠️ Advertencia
TypeScript verifica que todos los casos posibles sean manejados en el switch. Si añades un nuevo estado a la unión discriminada, el compilador te alertará sobre los lugares donde debes actualizarlo.

Template literal types para props especializadas

Los template literal types (introducidos en TypeScript 4.1) permiten crear tipos más expresivos y específicos:

// Definir variantes de color para un botón
type ButtonVariant = 'primary' | 'secondary' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';

// Generar clases CSS automaticamente
type ButtonColorClass = `btn-${ButtonVariant}`;
type ButtonSizeClass = `btn-${ButtonSize}`;
type ButtonClass = `${ButtonColorClass} ${ButtonSizeClass}`;

interface StyledButtonProps {
  variant: ButtonVariant;
  size: ButtonSize;
}

function StyledButton({ variant, size, ...props }: StyledButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
  const className: ButtonClass = `btn-${variant} btn-${size}`;
  return <button className={className} {...props} />;
}

// Uso
<StyledButton variant="primary" size="md" onClick={() => {}}>
  Click me
</StyledButton>

Type-safe hooks

Los hooks revolucionaron la forma de manejar estado y efectos secundarios en React. TypeScript nos ayuda a usarlos de manera segura y expresiva.

Tipado correcto de useState y useReducer

El hook useState puede inferir tipos automáticamente, pero a veces es necesario ser explícito:

// Inferencia automática básica
const [count, setCount] = useState(0); // count es inferido como number

// Tipo explícito para valores inicialmente undefined
type User = { id: number; name: string };
const [user, setUser] = useState<User | null>(null);

// Con useReducer para estados complejos
type State = { count: number; isActive: boolean };

type Action = 
  | { type: 'increment' } 
  | { type: 'decrement' } 
  | { type: 'toggle' };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + 1 };
    case 'decrement':
      return { ...state, count: state.count - 1 };
    case 'toggle':
      return { ...state, isActive: !state.isActive };
  }
}

// Uso
const [state, dispatch] = useReducer(reducer, { count: 0, isActive: false });

// TypeScript garantiza que solo se pueden despachar acciones válidas
dispatch({ type: 'increment' }); // ✅
dispatch({ type: 'reset' }); // ❌ Error: Argument of type '{ type: "reset"; }' is not assignable...

Creación de custom hooks tipados

Los custom hooks permiten extraer y reutilizar lógica stateful. TypeScript nos ayuda a definir entradas y salidas precisas:

function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });

  const setValue = (value: T) => {
    try {
      setStoredValue(value);
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error(error);
    }
  };

  return [storedValue, setValue];
}

// Uso con tipo explícito
const [user, setUser] = useLocalStorage<User | null>('user', null);

// O con inferencia
const [preferences, setPreferences] = useLocalStorage('preferences', { 
  darkMode: true, 
  fontSize: 16 
});
// preferences es inferido como { darkMode: boolean; fontSize: number }

Inference y generics en hooks personalizados

Los hooks personalizados pueden aprovechar la inferencia de tipos para proporcionar una experiencia de desarrollo fluida:

function useAsyncData<T, E = Error>(
  fetchFn: () => Promise<T>,
  deps: React.DependencyList = []
) {
  const [state, setState] = useState<AsyncState<T>>({ status: 'loading' });

  useEffect(() => {
    let mounted = true;
    setState({ status: 'loading' });
    
    fetchFn()
      .then(data => {
        if (mounted) {
          setState({ status: 'success', data });
        }
      })
      .catch(error => {
        if (mounted) {
          setState({ 
            status: 'error', 
            error: error instanceof Error ? error.message : String(error)
          });
        }
      });
      
    return () => { mounted = false; };
  }, deps);

  return state;
}

// Uso - TypeScript infiere correctamente el tipo de data
function UserProfile({ userId }: { userId: number }) {
  const state = useAsyncData(() => 
    fetch(`/api/users/${userId}`).then(res => res.json())
  , [userId]);

  if (state.status === 'loading') return <Spinner />;
  if (state.status === 'error') return <div>Error: {state.error}</div>;
  
  // TypeScript sabe que state.data existe en este punto
  return <div>Hello, {state.data.name}!</div>;
}

Técnicas avanzadas para Props

El sistema de props es el corazón de la composición en React. TypeScript nos permite modelar patrones avanzados que serían difíciles o propensos a errores sin tipos.

Props condicionales con Discriminated Unions

Las props condicionales permiten que un componente tenga diferentes configuraciones de props basadas en una propiedad discriminante:

// Botón que puede ser un anchor <a> o un <button>
type ButtonBaseProps = {
  children: React.ReactNode;
  className?: string;
};

type ButtonAsButton = ButtonBaseProps & {
  as: 'button';
  onClick: () => void;
  href?: never; // Nunca permitido en este caso
};

type ButtonAsAnchor = ButtonBaseProps & {
  as: 'a';
  href: string;
  onClick?: never; // Nunca permitido en este caso
};

type SmartButtonProps = ButtonAsButton | ButtonAsAnchor;

function SmartButton(props: SmartButtonProps) {
  const { as, children, className, ...rest } = props;
  
  if (as === 'button') {
    return (
      <button className={className} onClick={props.onClick} {...rest}>
        {children}
      </button>
    );
  } else {
    return (
      <a className={className} href={props.href} {...rest}>
        {children}
      </a>
    );
  }
}

// Uso
<SmartButton as="button" onClick={() => alert('Clicked')}>
  Click me
</SmartButton>

<SmartButton as="a" href="https://example.com">
  Visit site
</SmartButton>
🔍 Detalle técnico
La propiedad never es clave aquí - asegura que propiedades mutuamente excluyentes no puedan estar presentes simultáneamente, creando una verdadera relación "o esto o aquello".

Props recursivas para estructuras anidadas

Las estructuras de datos anidadas como árboles o menús requieren tipos recursivos:

interface TreeNodeProps<T> {
  label: string;
  value: T;
  children?: TreeNodeProps<T>[];
}

function TreeNode<T>({ label, children }: TreeNodeProps<T>) {
  const [expanded, setExpanded] = useState(false);
  
  return (
    <div>
      <div onClick={() => setExpanded(!expanded)}>
        {label} {children?.length ? (expanded ? '🔽' : '▶️') : ''}
      </div>
      
      {expanded && children && (
        <div style={{ paddingLeft: 20 }}>
          {children.map((child, index) => (
            <TreeNode key={index} {...child} />
          ))}
        </div>
      )}
    </div>
  );
}

// Uso
const fileTree: TreeNodeProps<string>[] = [
  {
    label: 'src',
    value: 'src/',
    children: [
      { label: 'components', value: 'src/components/' },
      { label: 'hooks', value: 'src/hooks/' }
    ]
  },
  { label: 'package.json', value: 'package.json' }
];

<div>
  {fileTree.map((node, index) => (
    <TreeNode key={index} {...node} />
  ))}
</div>

Prop drilling type-safe

El prop drilling (pasar props a través de múltiples niveles de componentes) puede hacerse más seguro con TypeScript mediante el uso de tipos bien definidos:

// Definir un tipo para el conjunto de props que serán "drilled"
interface UserContextProps {
  username: string;
  isAdmin: boolean;
  preferences: {
    theme: 'light' | 'dark';
    fontSize: number;
  };
}

// Componente contenedor que acepta estas props
interface AppContainerProps {
  userContext: UserContextProps;
  children: React.ReactNode;
}

// Componente intermedio que pasa las props
interface SidebarProps {
  userContext: UserContextProps;
}

// Componente final que usa las props
interface UserInfoProps {
  userContext: UserContextProps;
}

function AppContainer({ userContext, children }: AppContainerProps) {
  return (
    <div>
      <Sidebar userContext={userContext} />
      <main>{children}</main>
    </div>
  );
}

function Sidebar({ userContext }: SidebarProps) {
  return (
    <aside>
      <UserInfo userContext={userContext} />
      <nav>{/* ... */}</nav>
    </aside>
  );
}

function UserInfo({ userContext }: UserInfoProps) {
  return (
    <div>
      <h3>Welcome, {userContext.username}</h3>
      <p>Theme: {userContext.preferences.theme}</p>
    </div>
  );
}
💡 Pro tip
Aunque el prop drilling puede ser type-safe, considere usar Context API para props que necesitan ser accedidas por muchos componentes en diferentes niveles para reducir la verbosidad.

Context API con TypeScript

La Context API de React proporciona una forma de compartir valores entre componentes sin pasarlos explícitamente a través de props. TypeScript mejora esta experiencia asegurando que los consumers accedan a valores correctamente tipados.

Type-safe Context

Crear un contexto type-safe requiere definir tanto el tipo de los valores compartidos como potencialmente un valor por defecto:

interface ThemeContextType {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

// Crear el contexto con un valor inicial seguro
const ThemeContext = React.createContext<ThemeContextType | undefined>(undefined);

// Un hook personalizado para acceder al contexto con seguridad de tipos
function useThemeContext(): ThemeContextType {
  const context = React.useContext(ThemeContext);
  if (context === undefined) {
    throw new Error('useThemeContext must be used within a ThemeProvider');
  }
  return context;
}

Creación de providers con inferencia de tipos

Los providers pueden ser creados de manera que TypeScript infiera automáticamente los tipos correctos:

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  
  const toggleTheme = useCallback(() => {
    setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
  }, []);
  
  // El valor es inferido correctamente como ThemeContextType
  const value = { theme, toggleTheme };
  
  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}

Consumo seguro de múltiples contexts

En aplicaciones complejas, es común tener múltiples contextos. TypeScript nos ayuda a combinarlos de manera segura:

// Definición de múltiples contextos
interface AuthContextType {
  user: User | null;
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
}

interface NotificationContextType {
  notifications: Notification[];
  addNotification: (notification: Notification) => void;
  clearNotifications: () => void;
}

// Hooks para cada contexto
function useAuth(): AuthContextType {
  const context = React.useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

function useNotifications(): NotificationContextType {
  const context = React.useContext(NotificationContext);
  if (context === undefined) {
    throw new Error('useNotifications must be used within a NotificationProvider');
  }
  return context;
}

// Componente que consume múltiples contextos
function UserProfile() {
  const { user, logout } = useAuth();
  const { notifications, clearNotifications } = useNotifications();
  
  if (!user) return <LoginPrompt />;
  
  return (
    <div>
      <h2>Welcome, {user.name}</h2>
      <button onClick={logout}>Logout</button>
      
      <div>
        <h3>Notifications ({notifications.length})</h3>
        {notifications.map(n => (
          <NotificationItem key={n.id} notification={n} />
        ))}
        {notifications.length > 0 && (
          <button onClick={clearNotifications}>Clear all</button>
        )}
      </div>
    </div>
  );
}

Tipado de patrones comunes en React

React es conocido por sus patrones de composición flexibles. TypeScript nos permite implementarlos con seguridad de tipos.

HOCs tipados correctamente

Los Higher-Order Components (HOCs) pueden ser desafiantes de tipar correctamente. El truco está en usar generics para preservar los tipos del componente envuelto:

// HOC que añade una propiedad de tema a cualquier componente
interface WithThemeProps {
  theme: 'light' | 'dark';
}

// El tipo genérico P representa las props originales del componente
function withTheme<P>(WrappedComponent: React.ComponentType<P & WithThemeProps>) {
  // El resultado es un componente que toma las props originales sin el tema
  const WithTheme: React.FC<Omit<P, keyof WithThemeProps>> = props => {
    // Usamos nuestro hook para obtener el tema
    const { theme } = useThemeContext();
    
    // Pasamos las props originales + tema al componente envuelto
    return <WrappedComponent {...props as P} theme={theme} />;
  };
  
  // Establecer displayName para DevTools
  WithTheme.displayName = `WithTheme(${getDisplayName(WrappedComponent)})`;
  
  return WithTheme;
}

// Función auxiliar para obtener el nombre del componente
function getDisplayName<P>(WrappedComponent: React.ComponentType<P>): string {
  return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}

// Uso del HOC
interface ButtonProps extends WithThemeProps {
  label: string;
  onClick: () => void;
}

const Button: React.FC<ButtonProps> = ({ label, onClick, theme }) => {
  const className = `btn btn-${theme}`;
  return <button className={className} onClick={onClick}>{label}</button>;
};

// ThemeButton no requiere prop 'theme' porque la añade el HOC
const ThemeButton = withTheme(Button);

// Uso
<ThemeButton 
  label="Click me" 
  onClick={() => console.log('clicked')} 
/>;

Render props con tipos precisos

El patrón render props es otra forma común de compartir código entre componentes:

interface DataProviderProps<T> {
  fetchData: () => Promise<T>;
  renderLoading: () => React.ReactNode;
  renderError: (error: string) => React.ReactNode;
  renderData: (data: T) => React.ReactNode;
}

function DataProvider<T>({ 
  fetchData, 
  renderLoading, 
  renderError, 
  renderData 
}: DataProviderProps<T>) {
  const [state, setState] = useState<AsyncState<T>>({ status: 'loading' });
  
  useEffect(() => {
    let mounted = true;
    
    setState({ status: 'loading' });
    fetchData()
      .then(data => {
        if (mounted) {
          setState({ status: 'success', data });
        }
      })
      .catch(error => {
        if (mounted) {
          setState({ 
            status: 'error', 
            error: error instanceof Error ? error.message : String(error)
          });
        }
      });
      
    return () => { mounted = false; };
  }, [fetchData]);
  
  switch (state.status) {
    case 'loading':
      return <>{renderLoading()}</>;
    case 'error':
      return <>{renderError(state.error)}</>;
    case 'success':
      return <>{renderData(state.data)}</>;
  }
}

// Uso
function UserList() {
  return (
    <DataProvider<User[]>
      fetchData={() => fetch('/api/users').then(res => res.json())}
      renderLoading={() => <Spinner />}
      renderError={(error) => <ErrorMessage message={error} />}
      renderData={(users) => (
        <ul>
          {users.map(user => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
    />
  );
}