Sincronização bidirecional entre Schema e tipos TypeScript
Resumo: Tipos e schemas cobrem cada um um segmento: um em tempo de compilação, outro em tempo de execução. A questão não é qual escolher, mas como manter os dois sincronizados automaticamente.

Resumo da ópera: escolha primeiro uma fonte de verdade — schema-first quando o backend é dono do contrato, types-first no estilo zod quando o frontend é — depois gere a outra cópia e compare por diff no CI; escreva à mão apenas a semântica que o gerador não consegue expressar, como tipos com marca (branded types).
Se você escrever as duas definições à mão, elas vão divergir. O preço da divergência é "compila, e depois retorna um 500 em tempo de execução".
1. Por que manter apenas uma cópia não funciona
| Capacidade | Tipos TypeScript | JSON Schema |
|---|---|---|
| Verificação em tempo de compilação | ✓ | ✗ |
| Validação em tempo de execução | ✗ (tipos são apagados) | ✓ |
| Uso entre linguagens | ✗ | ✓ (Java/Go/Python conseguem todos ler) |
| Autocompletar e refatoração na IDE | ✓ | Mais ou menos |
| Geração de documentação | Mais ou menos | ✓ (o ecossistema OpenAPI) |
A entrada de uma API vem da rede e é sempre unknown, então a validação em tempo de execução não pode ser pulada; e ao escrever código de negócio você ainda precisa de autocompletar de tipos.
A abordagem sensata é escolher um como fonte de verdade e gerar o outro.
2. Caminho A: schema primeiro
Adequado a: trabalho guiado pelo backend, uso entre linguagens e contratos públicos.
pnpm add -D json-schema-to-typescript
import { compileFromFile } from 'json-schema-to-typescript';
import { writeFileSync } from 'node:fs';
/**
* Gera definições de tipo TypeScript a partir de um JSON Schema.
* @param schemaPath Caminho do arquivo de schema
* @param outputPath Caminho do arquivo de saída
* @returns Uma Promise que resolve quando a geração termina
*/
async function generateTypes(schemaPath: string, outputPath: string): Promise<void> {
const typeText = await compileFromFile(schemaPath, {
bannerComment: '/* Gerado automaticamente a partir do schema. Não edite à mão. */',
additionalProperties: false,
style: { singleQuote: true },
});
writeFileSync(outputPath, typeText, 'utf8');
}
Algumas convenções na saída vale observar:
required: ["id"]→id: string; chaves não listadas emrequired→id?: string;"type": ["string", "null"]→string | null;enum→ uma union de tipos literais, bem mais precisa questring;additionalProperties: falsedetermina se uma assinatura de índice é gerada.
Há também uma opção sem etapa de build, o json-schema-to-ts, que infere diretamente no nível de tipo:
import type { FromSchema } from 'json-schema-to-ts';
const userSchema = {
type: 'object',
properties: { id: { type: 'string' }, age: { type: 'integer' } },
required: ['id'],
additionalProperties: false,
} as const;
type User = FromSchema<typeof userSchema>;
// { id: string; age?: number }
A vantagem é não ter etapa de geração e tipos sempre sincronizados com o schema; a desvantagem é que o schema precisa ser escrito como as const, e cenários complexos com $ref compilam devagar.
3. Caminho B: tipos primeiro
Adequado a: trabalho guiado pelo frontend com bibliotecas como zod / valibot, onde "o schema é o tipo".
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
export const userSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(64),
age: z.number().int().min(0).max(150).optional(),
tags: z.array(z.string()).default([]),
});
export type User = z.infer<typeof userSchema>;
/** O contrato publicado para outras linguagens */
export const userJsonSchema = zodToJsonSchema(userSchema, {
target: 'jsonSchema2020-12',
$refStrategy: 'none',
});
Aqui o zod faz os dois papéis — "definição de tipo" e "validação em tempo de execução" — e o JSON Schema é apenas um artefato de exportação. Este é o caminho de menor esforço no total, mas lembre de duas coisas:
z.infere o JSON Schema gerado não mapeiam exatamente paradefault,transformerefine;- O schema gerado pode conter
$refedefinitions, então verifique antes de publicar se corresponde à especificação alvo.
4. A direção inversa também precisa de verificação
Qualquer que seja o caminho escolhido, adicione uma verificação de CI: os artefatos gerados precisam corresponder aos arquivos no repositório.
# Gere em um diretório temporário e compare; falhe o CI em qualquer diferença
node scripts/generate-types.mjs --out .tmp/types.d.ts
diff -u src/types/api.d.ts .tmp/types.d.ts
Sem essa etapa, três meses depois ninguém sabe se api.d.ts está atualizado.
5. Quatro armadilhas de alta frequência
5.1 optional e nullable não são a mesma coisa
type A = { name?: string }; // a chave pode estar ausente
type B = { name: string | null }; // a chave precisa existir, mas o valor pode ser null
Em termos de schema:
{ "properties": { "name": { "type": "string" } } } // opcional
{ "required": ["name"], "properties": { "name": { "type": ["string", "null"] } } } // anulável
Geradores tratam os dois casos de forma completamente diferente. O contrato precisa declarar qual você quer dizer; caso contrário o data.name?.length do frontend quebrará em null.
5.2 Enums em evolução
type Status = 'pending' | 'paid' | 'refunded';
Se o backend adiciona 'cancelled' e os tipos do frontend não são atualizados, o código cai no ramo default.
Campos no estilo de enum devem tratar valores desconhecidos explicitamente:
function normalizeStatus(raw: string): Status | 'unknown' {
return raw === 'pending' || raw === 'paid' || raw === 'refunded' ? raw : 'unknown';
}
5.3 Datas e dinheiro não podem simplesmente usar string
No schema, format: "date-time" é apenas uma anotação; em TypeScript é só string.
Quando precisar de uma garantia mais forte, use um "branded type":
type IsoDateTime = string & { readonly __brand: 'IsoDateTime' };
/**
* Marca uma string como timestamp ISO.
* @param value Uma string cujo formato já foi verificado
* @returns A string de timestamp com marca
*/
function asIsoDateTime(value: string): IsoDateTime {
return value as IsoDateTime;
}
5.4 Inteiros grandes
type: "integer" não é equivalente a number uma vez que os valores passam de 2^53. Escreva no contrato como:
{ "type": "string", "pattern": "^[0-9]+$", "description": "Número do pedido; passa de 2^53, precisa ser string" }
6. Um processo de implantação sugerido
- Defina o contrato: comece escrevendo a estrutura de requisição/resposta como um schema (ou zod);
- Gere os tipos: gere
.d.tsno CI e commite-o no repositório para que as diferenças sejam fáceis de revisar; - Gere os validadores: compile o mesmo schema em uma função de validação do Ajv e chame-a em todo ponto de entrada do servidor;
- Gere a documentação: emita Markdown ou OpenAPI a partir do schema para que a documentação nunca envelheça à mão;
- Regressão: rode fixtures de API por "verificação de tipos" e "validação de schema"; só passar nos dois conta.
7. Lista de verificação
- [ ] Existe exatamente uma fonte de verdade; a outra cópia é gerada por um script;
- [ ] O script de geração roda no CI, e qualquer diferença de artefato falha o build;
- [ ]
optionalenullableestão explicitamente distinguidos no contrato; - [ ] Campos de enum têm fallback para valores desconhecidos;
- [ ] Inteiros grandes, dinheiro e tempo têm convenções explícitas tanto no schema quanto nos tipos;
- [ ] Arquivos de tipo gerados carregam um cabeçalho "não edite à mão".
Perguntas frequentes
Como decido entre schema-first e types-first?
Escolha schema-first quando o backend é dono do contrato, precisa de uso entre linguagens ou publica o contrato publicamente, e gere os tipos com json-schema-to-typescript. Escolha uma biblioteca no estilo zod de "o schema é o tipo" quando o frontend lidera e quer tipos e validação definidos uma vez, e depois exporte JSON Schema. Qualquer caminho precisa de uma etapa de CI que compare os artefatos gerados com os arquivos do repositório.
Por que definições de tipo e schema escritas à mão sempre divergem?
Tipos são apagados em tempo de compilação enquanto schemas só valem em tempo de execução, e os dois são editados por pessoas diferentes em momentos diferentes. Sem geração e comparação, uma cópia é atualizada enquanto a outra fica parada, o que produz "compila, e depois retorna um 500 em tempo de execução".
Devo commitar os arquivos de tipo gerados no repositório?
Sim. Commitá-los permite que revisores vejam mudanças de contrato, e o CI consegue comparar a geração nova com os arquivos do repositório e falhar em qualquer diferença. Deixe os artefatos apenas em uma pasta local ou no cache de build e ninguém saberá três meses depois se estão atualizados.