JSON Toolkit

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.

Intermediário~11 min de leituraPublicado em 2026-10-30Atualizado em 2026-10-30
Capa de Sincronização bidirecional entre Schema e tipos TypeScript

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 em required → id?: string;
  • "type": ["string", "null"] → string | null;
  • enum → uma union de tipos literais, bem mais precisa que string;
  • additionalProperties: false determina 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.infer e o JSON Schema gerado não mapeiam exatamente para default, transform e refine;
  • O schema gerado pode conter $ref e definitions, 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

  1. Defina o contrato: comece escrevendo a estrutura de requisição/resposta como um schema (ou zod);
  2. Gere os tipos: gere .d.ts no CI e commite-o no repositório para que as diferenças sejam fáceis de revisar;
  3. 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;
  4. Gere a documentação: emita Markdown ou OpenAPI a partir do schema para que a documentação nunca envelheça à mão;
  5. 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;
  • [ ] optional e nullable estã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.