Escrevendo uma biblioteca TypeScript com declarações de tipo

1. Visão geral

Usuários de bibliotecas TypeScript precisam de duas coisas: código JavaScript para executar e arquivo de declaração de tipo (.d.ts) para o editor fornecer dicas, e para o compilador capturar erros. Se faltar .d.ts, ou se package.json apontar para o arquivo errado, o usuário receberá any em todo lugar ou o erro Could not find a declaration file for module.

Este artigo constrói uma pequena biblioteca chamada order-pricing, usada para calcular o preço de pedidos. Ao terminar, você saberá como configurar tsconfig.json para gerar .d.ts, declarar exports em package.json, escrever declaration manualmente para código JavaScript legado, e testar tanto runtime quanto tipos antes de publicar.

2. Inicializar o projeto

Você cria um diretório e instala as ferramentas necessárias:

mkdir order-pricing && cd order-pricing
npm init -y
npm install --save-dev typescript vitest

TypeScript só precisa estar em devDependencies. Usuários da biblioteca não precisam do compilador, eles apenas usam os arquivos .js e .d.ts já compilados.

2.1. Configurar tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "rootDir": "src",
    "outDir": "dist",
    "isolatedDeclarations": true
  },
  "include": ["src"],
  "exclude": ["src/**/*.test.ts"]
}

Significado das opções importantes:

  • declaration: true requer que o tsc gere arquivo .d.ts ao lado de cada arquivo .js. Esta é a opção central deste artigo.
  • declarationMap: true gera arquivo .d.ts.map. Quando um usuário clica em “Go to Definition”, o editor abre o arquivo .ts original em vez do arquivo .d.ts. Para esse recurso funcionar, você precisa publicar juntamente com o diretório src.
  • module e moduleResolution configurados para NodeNext fazem o TypeScript resolver módulos exatamente como Node.js. Bibliotecas compiladas dessa forma funcionam tanto em bundlers quanto em Node puro.
  • isolatedDeclarations (disponível desde TypeScript 5.5) obriga todas as APIs exportadas a declarar tipos explicitamente, como o tipo de retorno de uma função. Com isso, outras ferramentas podem gerar .d.ts sem precisar executar o type checker. APIs públicas também ficam mais explícitas.
  • exclude remove arquivos de teste da compilação, evitando que testes entrem em dist.

2.2. Configurar package.json

{
  "name": "order-pricing",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  },
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": ["dist", "src"],
  "scripts": {
    "build": "tsc",
    "test": "vitest run",
    "prepublishOnly": "npm run test && npm run build"
  }
}

Pontos a observar:

  • "type": "module" faz com que os arquivos .js em dist sejam entendidos por Node como ESM.
  • Em exports, a condição types deve vir primeiro. TypeScript verifica as condições em ordem e para na primeira que corresponde. Se default vir antes, TypeScript nunca chegará a types.
  • main e types no nível raiz servem como fallback para ferramentas antigas que não leem exports.
  • files limita o que é enviado para npm. Sem esse campo, você corre o risco de publicar acidentalmente node_modules ou arquivos de configuração internos.

3. Escrever código da biblioteca

3.1. Definir tipos de dados

// src/types.ts
export type CustomerTier = "regular" | "silver" | "gold";

export interface Customer {
  id: string;
  name: string;
  tier: CustomerTier;
}

export interface OrderItem {
  sku: string;
  unitPrice: number;
  quantity: number;
}

export interface Order {
  id: string;
  customer: Customer;
  items: readonly OrderItem[];
}

items é declarado como readonly OrderItem[]. Assim, o usuário pode passar tanto arrays normais quanto readonly, ambos sendo válidos. A biblioteca também se compromete a não modificar o array do usuário.

3.2. Lógica de cálculo de preço

// src/pricing.ts
import type { CustomerTier, Order } from "./types.js";

const DISCOUNT_RATE: Record<CustomerTier, number> = {
  regular: 0,
  silver: 0.05,
  gold: 0.1,
};

/** Subtotal antes do desconto, em VND. */
export function calculateSubtotal(order: Order): number {
  return order.items.reduce(
    (sum, item) => sum + item.unitPrice * item.quantity,
    0,
  );
}

/** Total após aplicar desconto baseado no nível do cliente. */
export function calculateTotal(order: Order): number {
  const subtotal = calculateSubtotal(order);
  const discount = subtotal * DISCOUNT_RATE[order.customer.tier];
  return Math.round(subtotal - discount);
}

No código acima:

  • import type informa ao compilador que se trata apenas de uma importação de tipo. Esta linha é completamente removida do arquivo .js após a compilação.
  • O caminho de importação é escrito como ./types.js apesar do arquivo fonte ser types.ts. Com NodeNext, esta é a forma obrigatória, porque Node procura arquivos .js quando executado.
  • DISCOUNT_RATE não é exportado, então é um detalhe interno e não aparece em .d.ts.
  • Comentários do tipo /** ... */ são preservados em .d.ts. Usuários verão esse conteúdo quando passarem o mouse no editor.

3.3. Ponto de entrada (entry point)

// src/index.ts
export { calculateSubtotal, calculateTotal } from "./pricing.js";
export type { Customer, CustomerTier, Order, OrderItem } from "./types.js";

index.ts define a API pública da biblioteca. Você deve exportar também os tipos de dados. Caso contrário, o usuário teria que escrever Parameters<typeof calculateTotal>[0] apenas para obter o tipo Order.

4. Compilar e visualizar arquivos .d.ts

npm run build

Estrutura do diretório dist após a compilação:

dist/
├── index.d.ts
├── index.d.ts.map
├── index.js
├── index.js.map
├── pricing.d.ts
├── pricing.d.ts.map
├── pricing.js
├── pricing.js.map
├── types.d.ts
├── types.d.ts.map
├── types.js
└── types.js.map

Conteúdo de dist/pricing.d.ts:

import type { Order } from "./types.js";
/** Subtotal antes do desconto, em VND. */
export declare function calculateSubtotal(order: Order): number;
/** Total após aplicar desconto baseado no nível do cliente. */
export declare function calculateTotal(order: Order): number;
//# sourceMappingURL=pricing.d.ts.map

Este arquivo contém apenas assinaturas, não o corpo das funções. DISCOUNT_RATE também não está presente. dist/types.js é praticamente vazio (contém apenas export {};), porque interfaces e type aliases não existem em runtime.

Se você omitir o tipo de retorno : number em calculateTotal, a opção isolatedDeclarations reportará um erro:

src/pricing.ts(20,17): error TS9007: Function must have an explicit return type annotation with --isolatedDeclarations.

Este erro é benéfico. Se o tipo de retorno fosse inferido automaticamente, uma pequena mudança no corpo da função poderia mudar acidentalmente a API pública sem que você percebesse.

5. Testar runtime e tipos

Uma biblioteca com tipos precisa ser testada em dois aspectos: funções retornam os valores corretos, e os tipos de dados correspondem ao que você promete. Vitest suporta ambos.

// src/pricing.test.ts
import { describe, expect, expectTypeOf, it } from "vitest";
import { calculateSubtotal, calculateTotal, type Order } from "./index.js";

const goldOrder: Order = {
  id: "ORD-001",
  customer: { id: "C-01", name: "Lan", tier: "gold" },
  items: [
    { sku: "TEA-01", unitPrice: 150_000, quantity: 2 },
    { sku: "CUP-07", unitPrice: 200_000, quantity: 1 },
  ],
};

describe("calculateTotal", () => {
  it("calcula subtotal antes do desconto", () => {
    expect(calculateSubtotal(goldOrder)).toBe(500_000);
  });

  it("aplica 10% de desconto para cliente gold", () => {
    expect(calculateTotal(goldOrder)).toBe(450_000);
  });

  it("tem assinatura de tipo conforme prometido", () => {
    expectTypeOf(calculateTotal).parameter(0).toEqualTypeOf<Order>();
    expectTypeOf(calculateTotal).returns.toBeNumber();
  });

  it("rejeita nível de cliente inválido", () => {
    // @ts-expect-error "diamond" não faz parte de CustomerTier
    const tier: Order["customer"]["tier"] = "diamond";
    expect(tier).toBe("diamond");
  });
});

Análise de cada teste:

  • Os dois primeiros testes verificam a lógica: 2 × 150.000 + 200.000 = 500.000, após 10% de desconto resulta em 450.000.
  • expectTypeOf fixa a assinatura da função. Se alguém alterar o tipo de retorno para string depois, este teste falhará.
  • @ts-expect-error afirma que a próxima linha deve causar um erro de tipo. Se “diamond” se tornar válido um dia, TypeScript reportará que a diretiva é desnecessária.

Execute os testes:

npm test
 ✓ src/pricing.test.ts (4 tests) 3ms

 Test Files  1 passed (1)
      Tests  4 passed (4)

Nota: Por padrão, Vitest apenas transpila código, não faz type-check. @ts-expect-error e erros de tipo são verificados apenas quando você executa tsc --noEmit -p tsconfig.test.json (uma config separada que inclui arquivos de teste) ou vitest --typecheck. Você deve incluir este passo em sua CI.

5.1. Verificar o pacote antes de publicar

npm pack --dry-run
npm notice 📦  order-pricing@1.0.0
npm notice Tarball Contents
npm notice 1.1kB dist/index.d.ts
npm notice 312B  dist/index.js
...
npm notice 945B  src/pricing.ts
npm notice 402B  src/types.ts

Você lê essa lista para ter certeza de que tem todos os .d.ts e nenhum arquivo de teste ou secreto. A forma mais prática de verificar é executar npm pack, depois instalar o arquivo .tgz em um projeto de teste e importar. A ferramenta @arethetypeswrong/cli (comando attw --pack) também detecta a maioria dos erros de configuração de exports e types.

6. Escrever declaration manualmente para código JavaScript

Às vezes uma biblioteca mantém um módulo JavaScript legado que ainda não foi convertido para TypeScript. Você pode escrever manualmente o .d.ts para esse módulo.

// src/legacy-tax.js
export function applyVat(amount, rate = 0.08) {
  return Math.round(amount * (1 + rate));
}
// src/legacy-tax.d.ts
/**
 * Adiciona IVA ao valor.
 * @param amount Valor antes do imposto (VND).
 * @param rate Alíquota de imposto, padrão 0.08.
 */
export declare function applyVat(amount: number, rate?: number): number;

Depois você exporta novamente em index.ts como um módulo normal:

export { applyVat } from "./legacy-tax.js";

Nota: tsc não copia arquivos .js e .d.ts escritos manualmente para dist. Você tem duas opções. A primeira é ativar allowJs: true para que tsc copie legacy-tax.js para dist, depois copie legacy-tax.d.ts com um script, por exemplo "build": "tsc && cp src/legacy-tax.d.ts dist/". A segunda é ativar allowJs com JSDoc e deixar tsc gerar .d.ts a partir do JSDoc. Este é um erro muito comum: funciona localmente, mas após publicar o usuário recebe erro de módulo ausente.

Se você precisa adicionar tipos para um pacote de terceiros que não tem tipos, use declare module:

// src/shims/vn-currency.d.ts
declare module "vn-currency" {
  export function format(amount: number): string;
}

Você só deve fazer isso internamente no projeto. Se a API pública da sua biblioteca depender deste shim, os usuários não o verão.

7. Erros comuns

  • types vindo após default em exports: TypeScript não lê types, então o usuário recebe any ou erro TS7016.
  • Esquecer a extensão .js ao importar com NodeNext: tsc reporta erro TS2835. Se você evitar o erro mudando para moduleResolution: "bundler", a biblioteca pode funcionar em bundlers mas quebrar com Node puro.
  • Expor tipos de devDependencies: se Order usar um tipo de um pacote em devDependencies, o arquivo .d.ts importará esse pacote. Como o usuário não instala isso, ele recebe erros de tipo. Qualquer tipo que apareça na API pública deve estar em dependencies ou peerDependencies.
  • Confiar em skipLibCheck: esta opção acelera sua compilação, mas também oculta erros nos próprios arquivos .d.ts que você publica. Teste com um projeto de exemplo com skipLibCheck desativado.
  • Exportar enum ou namespace livremente: const enum causa problemas para usuários com isolatedModules ativado. Unions de string como CustomerTier geralmente são mais simples e seguras.
  • Alterar tipos públicos em uma versão patch: adicionar um campo obrigatório a Order é uma breaking change, mesmo que o código JavaScript não mude em nada. Considere .d.ts como parte da API ao versionar.

Quando você não precisa fazer todos esses passos? Se a biblioteca precisa exportar tanto ESM quanto CommonJS, use ferramentas de build especializadas como tsup ou tsdown em vez de configurar tsc duas vezes. Se o código é usado apenas internamente em um monorepo, TypeScript project references geralmente é uma solução mais concisa do que publicar no npm.

8. Conclusão

Este artigo construiu a biblioteca order-pricing do zero, com as seguintes etapas principais:

  • Ativar declaration, declarationMap e isolatedDeclarations em tsconfig.json para gerar .d.ts claros e estáveis.
  • Declarar exports em package.json com a condição types primeiro, e limitar arquivos publicados com files.
  • Testar tanto runtime quanto tipos com Vitest, expectTypeOf e @ts-expect-error.
  • Escrever .d.ts manualmente para código JavaScript legado e lembrar de incluí-los em dist.

A regra mais importante é considerar o arquivo .d.ts como um contrato com o usuário. Verifique o pacote de verdade com npm pack antes de cada publicação, pois muitos erros de configuração só aparecem no lado do usuário, não no seu projeto.

typescriptnpmtype-declarationlibrarytsconfig