Volver al Blog
Angular Development22 de mayo de 202410 min de lectura

Tipos utilitarios de TypeScript que todo desarrollador Angular debería conocer

DR

Doug Ribeiro

10 min de lectura

Los tipos utilitarios de TypeScript están integrados en la biblioteca estándar del lenguaje, vienen con cada versión de TypeScript, y la mayoría de los desarrolladores Angular los subutilizan. He hecho muchos code reviews a lo largo de los años y el mismo patrón aparece constantemente: un desarrollador escribe una nueva interfaz que es casi idéntica a una existente, diferenciándose solo en qué campos son requeridos o qué campos están incluidos. Para eso exactamente existen Partial, Pick, Omit y Required. Usarlos reduce la duplicación, mejora la refactorizabilidad y comunica intención. Así es como realmente se ven en código Angular.

Remodelar un tipo existentePartial<T>. Todos los campos op…Required<T>. Todos los campos o…Readonly<T>. InmutablePick<T, K>. Selecciona camposOmit<T, K>. Elimina camposConstruir un tipo a partir de otroRecord<K, V>. Mapa tipado de bú…ReturnType<T>. Tipo de retornoParameters<T>. Tipos de los par…Awaited<T>. Desenvuelve una Pro…VS

Partial: Deja de escribir interfaces de actualización separadas

Partial hace que cada propiedad de un tipo sea opcional. El caso de uso canónico en Angular son los payloads de PATCH y actualizaciones parciales de formularios. Antes de internalizar esto, escribía interfaces UpdateUser separadas que eran simplemente User con todo opcional. Eso es exactamente lo que Partial hace automáticamente.

typescript
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

interface UserProfile {
  id: string;
  firstName: string;
  lastName: string;
  email: string;
  timezone: string;
  plan: 'free' | 'pro' | 'team';
  seats: number;
}

@Injectable({ providedIn: 'root' })
export class UserProfileService {
  private http = inject(HttpClient);

  // No need for a separate UpdateUserProfile interface
  updateProfile(id: string, changes: Partial<UserProfile>): Observable<UserProfile> {
    return this.http.patch<UserProfile>(`/api/profiles/${id}`, changes);
  }
}

// TypeScript enforces that only valid fields are passed
this.userProfileService.updateProfile(id, {
  timezone: 'America/Toronto',
  plan: 'pro'
});

Required: Imponer completitud en un límite

Required invierte Partial. Cada propiedad opcional se vuelve no opcional. Lo uso en límites donde sé que un objeto parcialmente construido debería estar completo antes de proceder. Las fábricas de tests son el caso más común. Algo que la gente entiende mal: Required solo quita el signo de interrogación. No quita null ni undefined del tipo unión de una propiedad. Si un campo está tipado como string | null, Required<T> conserva el null.

typescript
interface DraftOrder {
  customerId?: string;
  totalAmount?: number;
  shippingMethod?: string;
  itemCount?: number;
}

function validateComplete(draft: DraftOrder): Required<DraftOrder> {
  const fields = ['customerId', 'totalAmount', 'shippingMethod', 'itemCount'] as const;
  for (const field of fields) {
    // Required removes the optional marker, so undefined is the only gap to check
    if (draft[field] === undefined) {
      throw new Error(`Order is missing required field: ${field}`);
    }
  }
  return draft as Required<DraftOrder>;
}

// In tests, Required<T> enforces complete mock objects
function createMockOrder(): Required<DraftOrder> {
  return {
    customerId: 'cust-123',
    totalAmount: 24900,
    shippingMethod: 'standard',
    itemCount: 3
  };
}

Pick: Limitar los inputs de componentes a lo que realmente usan

Pick crea un nuevo tipo que contiene solo las claves que especificas. Para inputs de componentes, esto es más informativo que pasar el objeto de dominio completo. Documenta lo que el componente realmente lee y evita que los componentes accedan a campos que no deberían importarles.

typescript
import { Component, Input } from '@angular/core';

interface Order {
  id: string;
  customerName: string;
  totalAmount: number;
  discountAmount: number | null;
  status: 'pending' | 'paid' | 'shipped' | 'cancelled';
  placedAt: Date;
  shippedAt: Date | null;
  fulfilledBy: string | null;
  warehouseNotes: string;
  weightKg: number;
}

// The card component needs none of the fulfilment fields
type OrderCardData = Pick<
  Order,
  'id' | 'customerName' | 'totalAmount' | 'status' | 'placedAt'
>;

@Component({ selector: 'app-order-card', standalone: true, template: '' })
export class OrderCardComponent {
  @Input({ required: true }) order!: OrderCardData;
}

Omit: Patrones de creación vs actualización

Omit es el complemento de Pick. Dame todo excepto estas claves. El patrón Angular más común son los payloads de creación donde los campos generados por el servidor aún no existen.

typescript
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

interface Order {
  id: string;
  status: 'pending' | 'paid' | 'shipped';
  placedAt: Date;
  shippedAt: Date | null;
  // These come from the user
  customerId: string;
  totalAmount: number;
  shippingMethod: string;
  itemCount: number;
}

// Server generates id, status, placedAt, shippedAt
type CreateOrderPayload = Omit<
  Order,
  'id' | 'status' | 'placedAt' | 'shippedAt'
>;

@Injectable({ providedIn: 'root' })
export class OrdersService {
  private http = inject(HttpClient);

  createOrder(payload: CreateOrderPayload): Observable<Order> {
    return this.http.post<Order>('/api/orders', payload);
  }
}

Record: Mapas de búsqueda tipados

Record crea un tipo de objeto con tipos de clave y valor definidos. Lo uso constantemente para objetos de configuración, mapas de estado y cualquier estructura tipo diccionario. Es más seguro que { [key: string]: something } porque las claves están tipadas.

typescript
type OrderStatus = 'pending' | 'paid' | 'packing' | 'shipped' | 'cancelled';

const statusLabels: Record<OrderStatus, string> = {
  'pending': 'Pending Payment',
  'paid': 'Paid',
  'packing': 'Packing',
  'shipped': 'Shipped',
  'cancelled': 'Cancelled'
};

// TypeScript enforces every status is covered
// Adding a new status to the union forces you to add the label
const statusColors: Record<OrderStatus, string> = {
  'pending': '#f59e0b',
  'paid': '#3b82f6',
  'packing': '#8b5cf6',
  'shipped': '#10b981',
  'cancelled': '#9ca3af'
};

ReturnType y Parameters: Trabajar con código que no es tuyo

Estos dos son poderosos cuando trabajas con funciones de terceros, clientes de API autogenerados o adaptadores legacy donde no puedes controlar las definiciones de tipos directamente. ReturnType extrae lo que retorna una función. Parameters extrae lo que acepta.

typescript
import { Injectable, inject } from '@angular/core';
import { Observable, from, catchError } from 'rxjs';

// Working with a legacy adapter you cannot modify
declare function fetchShippingRates(
  params: { postalCode: string; weightKg: number; carrier: 'standard' | 'express' }
): Promise<RawRateResponse>;

// Extract the types without duplicating them
type RateParams = Parameters<typeof fetchShippingRates>[0];
type RateResult = Awaited<ReturnType<typeof fetchShippingRates>>;

@Injectable({ providedIn: 'root' })
export class ShippingRatesService {
  private errorHandler = inject(ApiErrorHandler);

  // Use the extracted types in your Angular service wrapper
  getRates(params: RateParams): Observable<RateResult> {
    return from(fetchShippingRates(params)).pipe(
      catchError(err => this.errorHandler.handle(err))
    );
  }
}

Awaited es la pieza que hace esto usable. ReturnType de una función async te da una Promise, y Awaited la desenvuelve, así que el wrapper tipa el valor resuelto en lugar de la Promise. El caso clásico es un cliente de API autogenerado desde una especificación Swagger (OpenAPI). Los tipos generados no siempre son lo que necesitas para la capa Angular. ReturnType y Parameters te permiten construir wrappers sin duplicar las definiciones de tipos generados, que derivarían con cada regeneración de la API.

Readonly: Imponer inmutabilidad a nivel de tipo

Readonly hace que cada propiedad no sea reasignable. Con selectores de NgRx y signals, esto refuerza el patrón de que el código de vista nunca debería mutar el estado directamente.

typescript
import { Component, inject } from '@angular/core';
import { Store, createSelector } from '@ngrx/store';

export const selectCurrentOrder = createSelector(
  selectOrdersState,
  (state): Readonly<Order> | null =>
    state.currentOrder ? { ...state.currentOrder } : null
);

@Component({ selector: 'app-order-detail', standalone: true, template: '' })
export class OrderDetailComponent {
  private store = inject(Store);

  // The Readonly type prevents accidental mutation in the view layer
  readonly order = this.store.selectSignal(selectCurrentOrder);

  // TypeScript error if you try to mutate:
  // this.order().status = 'shipped'; // Cannot assign to 'status' because it is a read-only property
}

El salto de escribir TypeScript a pensar en TypeScript se nota exactamente en estos lugares: elegir un tipo utilitario integrado en lugar de escribir una nueva interfaz desde cero.

Empieza con Partial, Pick y Omit. Aparecen en casi cada servicio y límite de componente una vez que los buscas. Agrega Record para cualquier estructura de búsqueda o configuración que construyas. Gradúa a ReturnType y Parameters cuando estés envolviendo código que no puedes modificar. Estos no son malabares avanzados de tipos. Son el kit de herramientas estándar de cualquiera que escriba TypeScript mantenible.

Referencias

TypeScriptAngularFrontendBest Practices