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: truerequer que o tsc gere arquivo.d.tsao lado de cada arquivo.js. Esta é a opção central deste artigo.declarationMap: truegera arquivo.d.ts.map. Quando um usuário clica em “Go to Definition”, o editor abre o arquivo.tsoriginal em vez do arquivo.d.ts. Para esse recurso funcionar, você precisa publicar juntamente com o diretóriosrc.moduleemoduleResolutionconfigurados paraNodeNextfazem 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.tssem precisar executar o type checker. APIs públicas também ficam mais explícitas.excluderemove 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.jsem dist sejam entendidos por Node como ESM.- Em
exports, a condiçãotypesdeve vir primeiro. TypeScript verifica as condições em ordem e para na primeira que corresponde. Sedefaultvir antes, TypeScript nunca chegará atypes. mainetypesno nível raiz servem como fallback para ferramentas antigas que não leemexports.fileslimita o que é enviado para npm. Sem esse campo, você corre o risco de publicar acidentalmentenode_modulesou 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 typeinforma ao compilador que se trata apenas de uma importação de tipo. Esta linha é completamente removida do arquivo.jsapós a compilação.- O caminho de importação é escrito como
./types.jsapesar do arquivo fonte sertypes.ts. ComNodeNext, esta é a forma obrigatória, porque Node procura arquivos.jsquando executado. DISCOUNT_RATEnã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.
expectTypeOffixa a assinatura da função. Se alguém alterar o tipo de retorno para string depois, este teste falhará.@ts-expect-errorafirma 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-errore erros de tipo são verificados apenas quando você executatsc --noEmit -p tsconfig.test.json(uma config separada que inclui arquivos de teste) ouvitest --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
.jse.d.tsescritos manualmente para dist. Você tem duas opções. A primeira é ativarallowJs: truepara que tsc copielegacy-tax.jspara dist, depois copielegacy-tax.d.tscom um script, por exemplo"build": "tsc && cp src/legacy-tax.d.ts dist/". A segunda é ativarallowJscom JSDoc e deixar tsc gerar.d.tsa 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
typesvindo apósdefaultemexports: TypeScript não lêtypes, então o usuário recebeanyou erro TS7016.- Esquecer a extensão
.jsao importar comNodeNext: tsc reporta erro TS2835. Se você evitar o erro mudando paramoduleResolution: "bundler", a biblioteca pode funcionar em bundlers mas quebrar com Node puro. - Expor tipos de
devDependencies: seOrderusar um tipo de um pacote emdevDependencies, o arquivo.d.tsimportará 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 emdependenciesoupeerDependencies. - Confiar em
skipLibCheck: esta opção acelera sua compilação, mas também oculta erros nos próprios arquivos.d.tsque você publica. Teste com um projeto de exemplo comskipLibCheckdesativado. - Exportar
enumounamespacelivremente:const enumcausa problemas para usuários comisolatedModulesativado. Unions de string comoCustomerTiergeralmente 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.tscomo 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,declarationMapeisolatedDeclarationsemtsconfig.jsonpara gerar.d.tsclaros e estáveis. - Declarar
exportsempackage.jsoncom a condiçãotypesprimeiro, e limitar arquivos publicados comfiles. - Testar tanto runtime quanto tipos com Vitest,
expectTypeOfe@ts-expect-error. - Escrever
.d.tsmanualmente 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.