Tipos para funciones async y Promise en TypeScript
1. Descripción general
Las funciones async están presentes en casi todo código TypeScript moderno: llamar APIs, leer bases de datos, procesar colas. Escribir una función async es fácil. Declarar tipos para que el compilador atrape errores es más complicado.
Este artículo presenta cómo asignar tipos a funciones async y Promise. Los contenidos principales incluyen Promise<T>, Awaited<T>, tipos de Promise.all y Promise.allSettled, cómo manejar errores con tipos seguros, y helpers genéricos como timeout. Después de leerlo, evitarás errores difíciles de detectar como olvidar await o usar async en forEach.
2. Preparar el entorno
Los ejemplos usan TypeScript en modo strict y Vitest para las pruebas:
npm init -y
npm install -D typescript vitest
npx tsc --init --strict --target es2022 --module nodenext
La bandera --strict habilita useUnknownInCatchVariables. Esta bandera es importante para la parte de manejo de errores en la sección 5.
Todos los ejemplos usan un modelo simple compartido y una función que simula latencia de red:
// models.ts
export interface Order {
id: string;
customerId: string;
total: number;
status: 'PENDING' | 'PAID';
}
export interface Customer {
id: string;
name: string;
}
export const delay = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
delay devuelve Promise<void>. El tipo de una Promise que indica ‘completado’ sin llevar valor. Aquí debes escribir explícitamente new Promise<void>. Si no lo escribes, TypeScript infiere Promise<unknown>.
3. Tipo de retorno de la función async
3.1. Siempre es Promise<T>
La función async siempre devuelve una Promise. El valor que devuelves en su interior se envuelve en ella:
// order-service.ts
import { Order, Customer, delay } from './models';
const orders = new Map<string, Order>([
['o-1', { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PENDING' }],
]);
export async function fetchOrder(id: string): Promise<Order> {
await delay(50);
const order = orders.get(id);
if (!order) {
throw new Error(`Order ${id} not found`);
}
return order; // tipo Order, envuelto en Promise<Order>
}
export async function fetchCustomer(id: string): Promise<Customer> {
await delay(30);
return { id, name: 'Nguyễn Văn An' };
}
El tipo declarado es Promise<Order>, no Order. Si escribes async function fetchOrder(id: string): Order, el compilador reporta un error:
error TS1064: The return type of an async function or method must be the global Promise<T> type.
El comando throw dentro de una función async no cambia el tipo de retorno. El error se convierte en una Promise rechazada. El llamador la captura con try/catch alrededor de await, o con .catch().
3.2. ¿Inferencia o declaración explícita?
TypeScript infiere el tipo de retorno automáticamente si omites la anotación. Sin embargo, para funciones públicas deberías escribirlo explícitamente. Mira el siguiente ejemplo:
async function findOrder(id: string) {
const order = orders.get(id);
if (!order) return null;
return order;
}
// Tipo inferido: Promise<Order | null>
Este tipo es correcto, pero depende del cuerpo de la función. Supongamos que alguien cambia la rama inicial a return undefined. El tipo cambiará silenciosamente a Promise<Order | undefined>, y todos los lugares que verifican === null serán incorrectos sin ninguna advertencia. Cuando escribes Promise<Order | null> explícitamente, el compilador reporta un error justo en esa función.
4. Obtener el tipo ‘dentro’ de Promise con Awaited<T>
A veces tienes una función async y necesitas el tipo del valor después de await. ReturnType no es suficiente, porque devuelve la Promise en sí:
import { fetchOrder } from './order-service';
type R1 = ReturnType<typeof fetchOrder>; // Promise<Order>
type R2 = Awaited<ReturnType<typeof fetchOrder>>; // Order
type Nested = Awaited<Promise<Promise<number>>>; // number
Awaited<T> mimetiza correctamente el comportamiento de await. ‘Abre’ Promise de forma recursiva, incluso cuando las Promises se anidan. Este tipo es muy útil cuando escribes genéricos, o cuando una librería no exporta el tipo del resultado:
type OrderSummary = Pick<Awaited<ReturnType<typeof fetchOrder>>, 'id' | 'total'>;
// { id: string; total: number }
5. Tipo para errores: Promise no tiene parámetro de error
Promise<T> tiene solo un parámetro de tipo, destinado al valor cuando tiene éxito. No hay sintaxis de tipo Promise<Order, NotFoundError>. Por lo tanto, la variable en catch tiene tipo unknown:
async function printOrder(id: string): Promise<void> {
try {
const order = await fetchOrder(id);
console.log(order.total);
} catch (err) {
// err: unknown
if (err instanceof Error) {
console.error('Error:', err.message);
} else {
console.error('Error indefinido:', err);
}
}
}
await printOrder('o-404');
Error: Order o-404 not found
JavaScript permite throw con cualquier valor: cadena, número, objeto. Por lo tanto, unknown es el único tipo honesto para err. Debes estrechar el tipo con instanceof antes de acceder a err.message.
Si quieres que el error sea parte del tipo, puedes usar el patrón Result:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
async function attempt<T>(promise: Promise<T>): Promise<Result<T>> {
try {
return { ok: true, value: await promise };
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
return { ok: false, error };
}
}
const result = await attempt(fetchOrder('o-1'));
if (result.ok) {
console.log(result.value.status); // result.value: Order
} else {
console.log(result.error.message);
}
PENDING
La propiedad ok es un discriminante. Después de verificar if (result.ok), TypeScript sabe con certeza que result.value existe y tiene tipo Order. El llamador no puede ‘olvidar’ manejar el error, porque para obtener value debe verificar ok primero.
Nota: No envuelvas todas las funciones con
Result. Este patrón funciona con errores de negocio esperados de antemano, como ‘no encontrado’ o ‘sin stock’. Para errores de infraestructura como pérdida de conexión DB, déjalo que la excepción suba a la capa de manejo común.
6. Tipos de Promise.all, allSettled y race
6.1. Promise.all preserva el tipo tupla
Cuando pasas un array literal, TypeScript infiere el tipo para cada posición:
const [order, customer] = await Promise.all([
fetchOrder('o-1'),
fetchCustomer('c-1'),
]);
// order: Order, customer: Customer
console.log(`${customer.name} - ${order.total}`);
Nguyễn Văn An - 250000
Las dos solicitudes se ejecutan en paralelo. El tiempo total es alrededor de 50ms, no 80ms. El tipo del resultado es [Order, Customer], no (Order | Customer)[]. Gracias a esto puedes desestructurar sin necesidad de casting.
Esto solo es cierto si pasas el array directamente. Si creas el array primero y luego lo pasas, se pierde el tipo tupla:
const tasks = [fetchOrder('o-1'), fetchCustomer('c-1')];
const results = await Promise.all(tasks);
// results: (Order | Customer)[] -- se pierde información de posición
const tasksTuple = [fetchOrder('o-1'), fetchCustomer('c-1')] as const;
const [o, c] = await Promise.all(tasksTuple); // Order, Customer
Añadir as const ayuda a preservar el tipo tupla.
6.2. Promise.allSettled y estrechar el tipo
Promise.allSettled no rechaza. Cada elemento en el resultado tiene tipo PromiseSettledResult<T>:
const settled = await Promise.allSettled([
fetchOrder('o-1'),
fetchOrder('o-404'),
]);
for (const r of settled) {
if (r.status === 'fulfilled') {
console.log('OK', r.value.id); // r.value: Order
} else {
console.log('FAIL', r.reason); // r.reason: any
}
}
OK o-1
FAIL Error: Order o-404 not found
r.reason tiene tipo any, no unknown. Esta es una pequeña brecha en la librería estándar, así que aún necesitas verificar instanceof Error antes de usarlo.
6.3. Promise.race y helper timeout genérico
Promise.race devuelve el tipo union de las Promises. Combinado con Promise<never>, tenemos un helper timeout que preserva el tipo:
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Timeout después de ${ms}ms`)), ms),
);
return Promise.race([promise, timeout]);
}
const order = await withTimeout(fetchOrder('o-1'), 1000); // order: Order
Como timeout tiene tipo Promise<never>, el resultado de race es Promise<T | never>, es decir, Promise<T>. never desaparece del tipo union. Este es precisamente su significado: ‘esta rama nunca devuelve un valor’.
Nota: El ejemplo anterior no cancela
setTimeoutcuandopromisese completa primero. En código de producción, deberías guardar el id del temporizador y llamar aclearTimeoutenfinally. Otra forma es usarAbortSignal.timeout(ms)si la API subyacente soportasignal.
7. Tipos para callbacks async
7.1. La trampa de () => void
TypeScript permite asignar una función que devuelve Promise<void> a un tipo () => void. Esto parece inofensivo pero puede perder errores:
function onOrderPaid(handler: (order: Order) => void) {
const order: Order = { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PAID' };
handler(order); // La Promise devuelta se ignora
console.log('Handler fue llamado');
}
onOrderPaid(async (order) => {
await delay(10);
throw new Error('Falló el envío de correo'); // unhandled rejection!
});
Handler fue llamado
Error: Falló el envío de correo (unhandled rejection, puede hacer crash al proceso Node)
onOrderPaid no sabe que el handler es async. No hace await, así que nadie captura el error. La forma de arreglarlo es declarar claramente que el handler puede ser async, luego await it:
type OrderHandler = (order: Order) => void | Promise<void>;
async function onOrderPaidSafe(handler: OrderHandler): Promise<void> {
const order: Order = { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PAID' };
await handler(order); // await sobre un valor que no es Promise sigue siendo válido
}
El tipo void | Promise<void> acepta tanto handlers síncronos como asíncronos. await sobre un valor síncrono simplemente devuelve ese valor.
7.2. async en forEach
Este es el error más común. forEach acepta (item) => void así que ignora la Promise:
async function payAll(list: Order[]): Promise<void> {
list.forEach(async (o) => {
await delay(20);
o.status = 'PAID';
});
console.log(list.map((o) => o.status)); // todavía ['PENDING', ...]
}
Hay dos formas de arreglarlo, según lo necesites:
// Secuencial: de uno en uno
for (const o of list) {
await delay(20);
o.status = 'PAID';
}
// Paralelo: tipo de retorno es Promise<void[]>
await Promise.all(
list.map(async (o) => {
await delay(20);
o.status = 'PAID';
}),
);
Para atrapar estos errores de tipo automáticamente, habilita las reglas @typescript-eslint/no-floating-promises y @typescript-eslint/no-misused-promises en typescript-eslint. El compilador TypeScript solo no reporta errores para estos casos.
8. Probar comportamiento y tipos con Vitest
Vitest te permite probar tanto valores en runtime como tipos en tiempo de compilación:
// order-service.test.ts
import { describe, it, expect, expectTypeOf } from 'vitest';
import { fetchOrder, fetchCustomer } from './order-service';
import { withTimeout } from './with-timeout';
import { delay, type Order } from './models';
describe('order-service', () => {
it('devuelve orden cuando existe', async () => {
await expect(fetchOrder('o-1')).resolves.toMatchObject({
id: 'o-1',
status: 'PENDING',
});
});
it('rechaza cuando no encuentra la orden', async () => {
await expect(fetchOrder('o-404')).rejects.toThrow('Order o-404 not found');
});
it('withTimeout rechaza cuando promise tarda demasiado', async () => {
const slow = delay(200).then(() => 'listo');
await expect(withTimeout(slow, 50)).rejects.toThrow('Timeout después de 50ms');
});
it('mantiene tipos correctos para Promise.all y withTimeout', () => {
expectTypeOf(fetchOrder).returns.resolves.toEqualTypeOf<Order>();
expectTypeOf(withTimeout(fetchOrder('o-1'), 100)).resolves.toEqualTypeOf<Order>();
expectTypeOf(
Promise.all([fetchOrder('o-1'), fetchCustomer('c-1')]),
).resolves.toMatchTypeOf<[Order, { name: string }]>();
});
});
Ejecuta las pruebas:
npx vitest run --typecheck
✓ order-service.test.ts (4 tests)
Test Files 1 passed (1)
Tests 4 passed (4)
Hay tres puntos a considerar:
- Siempre
awaitantes deexpect(...).resolves/.rejects. Si lo omites, la prueba termina antes de que se resuelva la Promise y siempre pasará. .rejects.toThrow(...)verifica el mensaje del error rechazado.expectTypeOfno ejecuta lógica en runtime. La bandera--typecheckle pide a Vitest que ejecutetscpara confirmar tipos. Si alguien cambiawithTimeoutpara devolverPromise<unknown>, esta prueba falla.
9. Errores comunes
| Error | Consecuencia | Cómo solucionarlo |
|---|---|---|
Olvidar await al llamar función async |
Variable tiene tipo Promise<Order> en lugar de Order; error se pierde |
Habilita no-floating-promises; lee detenidamente el tipo al pasar el ratón |
if (fetchOrder(id)) |
Siempre true porque Promise es un objeto |
TypeScript 5.x reporta error de condición siempre verdadera; haz await primero |
async en forEach |
No espera, error no se captura | for...of o Promise.all(map) |
Acceder a err.message en catch |
Error de compilación cuando strict está habilitado |
Estrecha con instanceof Error |
Casting as Promise<Order> en datos de fetch |
Tipo ‘mentiroso’, error aparece en runtime | Valida datos con schema, luego asigna tipo |
El último error necesita más explicación. await response.json() devuelve Promise<any>. Escribir const order: Order = await res.json() compila, pero TypeScript no verifica nada. Los datos desde fuera del proceso siempre necesitan ser validados.
Nota: No todas las funciones que devuelven Promise necesitan la palabra clave
async. Si la función solo reenvia una Promise, por ejemploreturn repository.find(id), puedes omitirasync. Manténasynccuando en la función haytry/catchalrededor deawait. Si omitesasyncyawaiten ese caso,catchno capturará el error rechazado.
10. Conclusión
La función async siempre devuelve Promise<T>. Declara este tipo explícitamente para APIs públicas. Usa Awaited<T> cuando necesites obtener el tipo ‘abierto’ de Promise. Promise.all preserva el tipo tupla si pasas un array literal o usas as const. Combina Promise.race con Promise<never> para escribir helpers genéricos sin perder tipos.
Respecto a errores, Promise no lleva el tipo del error. La variable en catch es unknown, y el patrón Result es una opción cuando quieres que los errores de negocio sean explícitos en el tipo. Finalmente, sé cuidadoso con callbacks de tipo () => void y forEach. Habilita reglas de ESLint sobre Promise para capturar automáticamente errores que el compilador ignora.