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.
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.
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.
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.
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.
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.
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.
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.
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
- Tipos utilitarios de TypeScript. https://www.typescriptlang.org/docs/handbook/utility-types.html
- Decorador @Input de Angular. https://angular.dev/api/core/Input