TypeScript
Diseño de APIs tipadas
Principios para diseñar APIs tipadas claras: inferencia útil, estados válidos, errores explícitos, generics justificados y contratos estables para consumidores.
- Última actualización
- Actualizada
- Nivel
- Aplicación
TypeScript
Principios para diseñar APIs tipadas claras: inferencia útil, estados válidos, errores explícitos, generics justificados y contratos estables para consumidores.
Una API tipada no es buena únicamente porque no contiene any. Debe ser fácil de descubrir, inferir, usar correctamente y evolucionar sin obligar al consumidor a comprender su implementación interna.
entrada clara
↓
inferencia útil
↓
resultado preciso
↓
errores comprensibles
↓
contrato estableMejor:
const product = createProduct({
name: "Keyboard",
price: 100,
});Que exigir:
const product = createProduct<ProductInput, Product>(...);El consumidor debería especificar type arguments solo cuando la información no puede inferirse.
function mapValues<TInput, TOutput>(
values: readonly TInput[],
transform: (value: TInput) => TOutput,
): TOutput[] {
return values.map(transform);
}TInput se infiere desde el array y TOutput desde la callback.
function log<T>(value: T): void {}No conserva información para el consumidor. unknown es más honesto:
function log(value: unknown): void {}type SaveResult =
| { ok: true; product: Product }
| { ok: false; issues: ValidationIssue[] };La unión guía al consumidor hacia el narrowing correcto.
Peligroso:
type SaveResult = {
success: boolean;
product?: Product;
issues?: ValidationIssue[];
};Permite combinaciones imposibles y obliga a assertions.
Cuando todas las entradas comparten un mismo retorno:
function printId(value: string | number): void;Cuando existen formas de llamada concretas con retornos relacionados:
function read(key: "theme"): Theme;
function read(key: "pageSize"): number;Cuando la relación se repite para muchos tipos:
function first<T>(values: readonly T[]): T | undefined;searchProducts({
query: "keyboard",
page: 2,
includeInactive: false,
});Permite añadir opciones sin cambiar el significado de parámetros posicionales existentes.
type SearchOptions = {
query: string;
page?: number;
};La optional property no crea el default. La implementación debe normalizar:
const page = options.page ?? 1;function parseProduct(value: unknown): Product {
// validar
}La frontera acepta incertidumbre y devuelve una garantía específica.
No hagas lo contrario:
function parseProduct(value: Product): unknown;function calculateTotal(
items: readonly OrderItem[],
): Cents;Permite recibir arrays mutables y readonly mientras comunica que la función no necesita modificarlos.
No retornes una referencia mutable interna si el consumidor no debe cambiarla.
Anotar exports importantes protege cambios accidentales:
export function createClient(
options: ClientOptions,
): Client {
return new InternalClient(options);
}El consumidor depende de Client, no de cada miembro interno inferido de InternalClient.
export interface Client {
request(input: RequestInput): Promise<ResponseData>;
}
class InternalClient implements Client {
// caches, adapters y helpers privados
}La API puede cambiar internamente sin expandir su superficie pública.
export function configure<T extends MassiveConditionalType<...>>(...)Puede producir errores ilegibles y ralentizar el editor.
Expón aliases nombrados y resultados simplificados:
export type ApplicationConfig = Simplify<ResolvedConfig>;Los nombres y estructura de tipos afectan los diagnósticos.
Mejor:
type CreateProductInput = {
name: string;
priceInCents: Cents;
};Que una cadena de utilities donde el error solo muestra Omit<Partial<...>>.
ProductId
CreateProductInput
ProductNotFound
ProductRepositoryComunican intención mejor que:
Data
Options
ResultType
HandlerObjectfunction defineRoutes<
const TRoutes extends Record<string, Route>,
>(routes: TRoutes): TRoutes {
return routes;
}Conserva claves para autocomplete sin exigir as const al consumidor.
const routes = {
home: { path: "/", public: true },
admin: { path: "/admin", public: false },
} satisfies Record<string, Route>;Comprueba el contrato y mantiene claves concretas.
Una API debe declarar lo que realmente enviará:
function onProduct(
handler: (product: Product) => void,
): () => void;No uses un callback demasiado general o propiedades opcionales falsas para facilitar asignaciones.
function loadProduct(
id: ProductId,
options?: {
signal?: AbortSignal;
},
): Promise<Product>;Incluye cancelación cuando el runtime la soporta. Promise<Product> no modela el tipo del rechazo; documenta errores públicos o utiliza Result para fallos esperados.
Añadir una propiedad requerida a una interface pública rompe consumidores.
Opciones:
No todas las adiciones son compatibles solo porque TypeScript compile internamente.
Un contrato abierto puede usar interface merging o un generic registry.
interface PluginRegistry {}Un contrato cerrado puede utilizar type alias y union discriminada.
Elige apertura como una decisión de producto, no como consecuencia accidental de usar interface.
export type PublicUser = {
id: UserId;
name: string;
};No derives automáticamente desde una entidad interna si campos privados podrían filtrarse al serializar.
Una assertion localizada puede existir en la implementación:
return Object.fromEntries(entries) as Pick<T, K>;La API pública debe seguir siendo demostrablemente segura y la lógica debe tener pruebas.
Una librería que utiliza syntax o declarations nuevas puede elevar la versión mínima del consumidor.
Documenta:
type Parser<T> = (value: unknown) => T;
interface HttpClient {
get<T>(
path: string,
parser: Parser<T>,
options?: { signal?: AbortSignal },
): Promise<T>;
}El consumidor no elige T libremente: el parser demuestra qué retorna.
¿Por qué una API parse<T>(value: unknown): T tiene una experiencia engañosa?
Porque permite que el consumidor elija cualquier T sin proporcionar un parser o prueba runtime; el tipo promete una precisión que la implementación no puede garantizar.
TypeScript 7 y toolchain concentra los cambios dependientes de versión y cómo mantener editor, build y ecosistema alineados.