Tipagem para funções async e Promise em TypeScript

1. Visão geral

Funções async estão presentes na maioria do código TypeScript moderno: chamadas de API, leitura de banco de dados, processamento de filas. Escrever uma função async é fácil. Declarar tipos para ela de forma que o compilador capture erros é mais difícil.

Este artigo apresenta como declarar tipos para funções async e Promise. Os conteúdos principais incluem Promise<T>, Awaited<T>, tipos de Promise.all e Promise.allSettled, como tratar erros com segurança de tipo, e helpers genéricos como timeout. Após ler, você evitará erros sutis como esquecer await ou usar async dentro de forEach.

2. Preparando o ambiente

Os exemplos usam TypeScript em modo strict e Vitest para testes:

npm init -y
npm install -D typescript vitest
npx tsc --init --strict --target es2022 --module nodenext

A flag --strict ativa useUnknownInCatchVariables. Esta flag é importante para a seção de tratamento de erros em 5.

Todos os exemplos usam um modelo simples e uma função que simula latência de rede:

// 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 retorna Promise<void>. Este é o tipo de uma Promise que apenas indica “concluído” sem carregar um valor. Aqui você deve especificar new Promise<void>. Se não especificar, TypeScript infere Promise<unknown>.

3. Tipo de retorno de funções async

3.1. Sempre é Promise<T>

Uma função async sempre retorna uma Promise. O valor que você return dentro dela será encapsulado nela:

// 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(`Pedido ${id} não encontrado`);
  }
  return order; // tipo Order, encapsulado em Promise<Order>
}

export async function fetchCustomer(id: string): Promise<Customer> {
  await delay(30);
  return { id, name: 'Nguyễn Văn An' };
}

O tipo declarado é Promise<Order>, não Order. Se você escrever async function fetchOrder(id: string): Order, o compilador reporta um erro:

erro TS1064: O tipo de retorno de uma função async ou método deve ser o tipo global Promise<T>.

O comando throw dentro de uma função async não muda o tipo de retorno. O erro é convertido em uma Promise rejeitada. Quem chama a função captura usando try/catch em volta de await, ou usando .catch().

3.2. Inferência ou declaração explícita?

TypeScript infere automaticamente o tipo de retorno se você omitir a anotação. Mesmo assim, para funções públicas você deve especificar o tipo. Veja o exemplo a seguir:

async function findOrder(id: string) {
  const order = orders.get(id);
  if (!order) return null;
  return order;
}
// Tipo inferido: Promise<Order | null>

Este tipo está correto, mas depende do corpo da função. Suponha que alguém modifique o primeiro ramo para retornar undefined. O tipo silenciosamente muda para Promise<Order | undefined>, e todos os locais que verificam === null estarão errados sem aviso. Ao especificar Promise<Order | null> explicitamente, o compilador reporta erro imediatamente na função.

4. Obtendo o tipo “dentro” de Promise com Awaited<T>

Às vezes você tem uma função async e precisa do tipo do valor após await. ReturnType não é suficiente, pois retorna a própria Promise:

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> simula corretamente o comportamento de await. Ele “desembrulha” Promise recursivamente, inclusive quando Promises estão aninhadas. Este tipo é muito útil ao escrever genéricos, ou quando uma biblioteca não exporta o tipo do resultado:

type OrderSummary = Pick<Awaited<ReturnType<typeof fetchOrder>>, 'id' | 'total'>;
// { id: string; total: number }

5. Tipagem para erros: Promise não tem parâmetro de erro

Promise<T> tem apenas um parâmetro de tipo, reservado para o valor em caso de sucesso. Não há sintaxe Promise<Order, NotFoundError>. Portanto, a variável em catch tem 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('Erro:', err.message);
    } else {
      console.error('Erro desconhecido:', err);
    }
  }
}

await printOrder('o-404');
Erro: Pedido o-404 não encontrado

JavaScript permite throw de qualquer valor: string, número, objeto. Portanto, unknown é o único tipo honesto para err. Você deve estreitar o tipo usando instanceof antes de acessar err.message.

Se você quiser que o erro se torne parte do tipo, pode usar o padrão 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

A propriedade ok é um discriminante. Após verificar if (result.ok), TypeScript tem certeza de que result.value existe e tem tipo Order. Quem chama a função não pode “esquecer” de tratar o erro, pois para obter value deve verificar ok primeiro.

Nota: Não encapsule toda função com Result. Este padrão funciona bem para erros de negócio esperados, como “não encontrado” ou “fora de estoque”. Para erros de infraestrutura como perda de conexão com BD, deixe a exceção propagar até a camada de tratamento centralizado.

6. Tipos de Promise.all, allSettled e race

6.1. Promise.all preserva tipo de tupla

Ao passar um array literal, TypeScript infere tipos para cada posição:

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

As duas requisições rodam em paralelo. O tempo total é cerca de 50ms, não 80ms. O tipo do resultado é [Order, Customer], não (Order | Customer)[]. Assim você pode fazer destructuring sem precisar de casting.

Isto só é verdade quando você passa o array diretamente. Se criar o array antes e depois passá-lo, o tipo de tupla é perdido:

const tasks = [fetchOrder('o-1'), fetchCustomer('c-1')];
const results = await Promise.all(tasks);
// results: (Order | Customer)[]  -- informação de posição perdida

const tasksTuple = [fetchOrder('o-1'), fetchCustomer('c-1')] as const;
const [o, c] = await Promise.all(tasksTuple); // Order, Customer

Adicionar as const ajuda a preservar o tipo de tupla.

6.2. Promise.allSettled e estreitamento de tipo

Promise.allSettled nunca rejeita. Cada elemento no resultado tem 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('FALHA', r.reason);    // r.reason: any
  }
}
OK o-1
FALHA Erro: Pedido o-404 não encontrado

r.reason tem tipo any, não unknown. Este é um pequeno erro na biblioteca padrão, então você ainda deve verificar instanceof Error antes de usar.

6.3. Promise.race e helper genérico timeout

Promise.race retorna o tipo união das Promises. Combinado com Promise<never>, temos um helper timeout que preserva o tipo:

export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  const timeout = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error(`Timeout após ${ms}ms`)), ms),
  );
  return Promise.race([promise, timeout]);
}

const order = await withTimeout(fetchOrder('o-1'), 1000); // order: Order

Como timeout tem tipo Promise<never>, o resultado de race é Promise<T | never>, que é Promise<T>. never desaparece da tipo união. Este é justamente o significado: “este ramo nunca retorna um valor”.

Nota: O exemplo acima não cancela setTimeout quando promise termina primeiro. Em código production, você deve guardar o id do timer e chamar clearTimeout em finally. Outra opção é usar AbortSignal.timeout(ms) se a API subjacente suportar signal.

7. Tipos para callbacks async

7.1. A armadilha () => void

TypeScript permite atribuir uma função que retorna Promise<void> a um tipo () => void. Isto parece inofensivo mas pode engolir erros:

function onOrderPaid(handler: (order: Order) => void) {
  const order: Order = { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PAID' };
  handler(order); // Promise retornada é ignorada
  console.log('Handler chamado');
}

onOrderPaid(async (order) => {
  await delay(10);
  throw new Error('Falha ao enviar email'); // unhandled rejection!
});
Handler chamado
Erro: Falha ao enviar email   (unhandled rejection, pode fazer crash do process Node)

onOrderPaid não sabe que o handler é async. Ele não faz await, então o erro não é capturado por ninguém. A forma de corrigir é declarar explicitamente que o handler pode ser async, depois fazer await nele:

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 num valor não-Promise ainda é válido
}

O tipo void | Promise<void> aceita handlers síncronos e assíncronos. await num valor síncrono simplesmente retorna o próprio valor.

7.2. async dentro de forEach

Este é o erro mais comum. forEach espera (item) => void, então ignora 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)); // ainda ['PENDING', ...]
}

Há duas formas de corrigir, dependendo da necessidade:

// Sequencial: um pedido por vez
for (const o of list) {
  await delay(20);
  o.status = 'PAID';
}

// Paralelo: tipo de retorno é Promise<void[]>
await Promise.all(
  list.map(async (o) => {
    await delay(20);
    o.status = 'PAID';
  }),
);

Para capturar estes erros de tipo automaticamente, ative as rules @typescript-eslint/no-floating-promises e @typescript-eslint/no-misused-promises no typescript-eslint. O compilador TypeScript sozinho não reporta erros nestes casos.

8. Testando comportamento e tipos com Vitest

Vitest permite testar tanto valores em runtime quanto tipos em compile time:

// 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('retorna pedido quando existe', async () => {
    await expect(fetchOrder('o-1')).resolves.toMatchObject({
      id: 'o-1',
      status: 'PENDING',
    });
  });

  it('rejeita quando pedido não é encontrado', async () => {
    await expect(fetchOrder('o-404')).rejects.toThrow('Pedido o-404 não encontrado');
  });

  it('withTimeout rejeita quando promise demora muito', async () => {
    const slow = delay(200).then(() => 'concluído');
    await expect(withTimeout(slow, 50)).rejects.toThrow('Timeout após 50ms');
  });

  it('preserva tipos corretos para Promise.all e 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 }]>();
  });
});

Executar testes:

npx vitest run --typecheck
 ✓ order-service.test.ts (4 tests)
 Test Files  1 passed (1)
      Tests  4 passed (4)

Há três pontos importantes:

  • Sempre await antes de expect(...).resolves/.rejects. Se esquecer, o teste termina antes de a Promise ser resolvida e sempre passa.
  • .rejects.toThrow(...) verifica a mensagem do erro rejeitado.
  • expectTypeOf não executa lógica nenhuma em runtime. A flag --typecheck pede ao Vitest para executar tsc e confirmar os tipos. Se alguém mudar withTimeout para retornar Promise<unknown>, este teste falha.

9. Erros comuns

Erro Consequência Correção
Esquecer await ao chamar função async Variável tem tipo Promise<Order> em vez de Order; erro é engolido Ativar no-floating-promises; ler o tipo atentamente ao passar o mouse
if (fetchOrder(id)) Sempre true pois Promise é um objeto TypeScript 5.x reporta condição sempre verdadeira; faça await antes
async dentro de forEach Não espera, erro não é capturado for...of ou Promise.all(map)
Acessar err.message em catch Erro de compilação quando strict ativado Estreitar tipo com instanceof Error
Casting as Promise<Order> para dados de fetch Tipo “mente”, erro aparece em runtime Validar dados com schema depois atribuir tipo

O último erro merece mais explicação. await response.json() retorna Promise<any>. Escrever const order: Order = await res.json() compila, mas TypeScript não verifica nada. Dados vindos de fora do processo sempre precisam ser validados.

Nota: Nem toda função que retorna Promise precisa da palavra-chave async. Se a função apenas passa uma Promise adiante, por exemplo return repository.find(id), você pode omitir async. Mantenha async quando há try/catch em volta de await na função. Se omitir async e await neste caso, o catch não captará o erro de rejeição.

10. Conclusão

Funções async sempre retornam Promise<T>. Declare este tipo explicitamente para APIs públicas. Use Awaited<T> quando precisar do tipo “desembrulhado” de uma Promise. Promise.all preserva tipo de tupla se você passar um array literal ou usar as const. Combine Promise.race com Promise<never> para escrever helpers genéricos sem perder tipos.

Sobre erros, Promise não carrega tipo de erro. A variável em catch é unknown, e o padrão Result é a escolha quando você quer que erros de negócio apareçam explicitamente no tipo. Finalmente, tenha cuidado com callbacks de tipo () => void e forEach. Ative as rules ESLint sobre Promise para capturar automaticamente erros que o compilador deixa passar.

typescriptasyncpromisegenericsvitest