JSON para TypeScript: Gere tipos a partir de respostas de API
Se alguma vez chamou uma API REST em TypeScript e tipou a resposta como any, conhece a dor que se segue. O autocomplete desaparece. Os erros de escrita passam despercebidos. Um campo que julgava ser uma string acaba por ser null, e a sua aplicação rebenta em produção.
A solução? Converter as suas respostas JSON em tipos TypeScript adequados. Neste guia, vamos percorrer o mapeamento manual, as ferramentas automatizadas e as melhores práticas que mantêm as suas integrações de API sólidas como rocha.
Porquê os tipos TypeScript a partir de JSON são importantes
O sistema de tipos do TypeScript existe para apanhar erros antes de chegarem aos seus utilizadores. Quando modela uma resposta de API com tipos precisos, acontecem três coisas:
- O autocomplete funciona em todo o lado. O seu editor conhece a forma dos dados, pelo que deixa de adivinhar nomes de campos.
- As refactorizações tornam-se seguras. Renomeie uma propriedade e o compilador indica-lhe todos os locais que falham.
- Os erros em tempo de execução diminuem. Lida com
null,undefinede formas inesperadas na fronteira, em vez de o fazer no interior profundo da sua aplicação.
Sem tipos, está efectivamente a escrever JavaScript com passos extra. Com tipos, obtém um contrato vivo entre o seu código e os dados que consome.
Mapeamento manual de JSON para TypeScript
Comecemos pelos fundamentos. Dado um objecto JSON, como o transforma numa interface TypeScript?
Objectos básicos
Eis uma resposta simples de utilizador vinda de uma API:
{
"id": 42,
"username": "jdoe",
"email": "jdoe@example.com",
"isActive": true
}
A interface TypeScript correspondente:
interface User {
id: number;
username: string;
email: string;
isActive: boolean;
}
Cada valor JSON mapeia para um primitivo TypeScript: number, string ou boolean. As chaves do objecto tornam-se nomes de propriedades.
Objectos aninhados
As APIs reais raramente devolvem estruturas planas. Considere um utilizador com um endereço:
{
"id": 42,
"username": "jdoe",
"address": {
"street": "123 Main St",
"city": "Springfield",
"zipCode": "62704",
"country": "US"
}
}
Separe os objectos aninhados nas suas próprias interfaces:
interface Address {
street: string;
city: string;
zipCode: string;
country: string;
}
interface User {
id: number;
username: string;
address: Address;
}
Isto mantém cada tipo focado e reutilizável. Se outro endpoint também devolver um Address, utiliza a mesma interface.
Arrays
Quando um campo contém um array, anota o tipo do elemento:
{
"id": 42,
"username": "jdoe",
"roles": ["admin", "editor"],
"posts": [
{ "id": 1, "title": "Hello World" },
{ "id": 2, "title": "TypeScript Tips" }
]
}
interface Post {
id: number;
title: string;
}
interface User {
id: number;
username: string;
roles: string[];
posts: Post[];
}
Utilize a sintaxe Type[] para casos simples. Para genéricos complexos, Array<Type> pode ser mais claro, mas a consistência importa mais do que qual escolhe.
Campos opcionais
As APIs frequentemente devolvem campos que podem ou não estar presentes. Marque-os com ?:
// Por vezes a API devolve bio, por vezes não
{
"id": 42,
"username": "jdoe",
"bio": "Software developer"
}
interface User {
id: number;
username: string;
bio?: string; // Pode estar ausente da resposta
}
Um campo opcional (bio?: string) significa que a chave pode estar totalmente ausente. Isto é diferente de um campo anulável, onde a chave existe mas o valor pode ser null.
Lidar com casos extremos
As APIs de produção são confusas. Eis como lidar com os problemas mais comuns.
Campos anuláveis
Quando um campo pode ser explicitamente null:
{
"id": 42,
"username": "jdoe",
"avatarUrl": null,
"deletedAt": null
}
interface User {
id: number;
username: string;
avatarUrl: string | null;
deletedAt: string | null; // String de data ISO ou null
}
Se um campo pode estar tanto ausente como nulo, combine os dois:
interface User {
id: number;
username: string;
middleName?: string | null; // Pode estar ausente, ou presente mas nulo
}
Tipos de união
Alguns campos aceitam múltiplos tipos. Uma API de pesquisa pode devolver resultados mistos:
{
"results": [
{ "type": "user", "id": 1, "username": "jdoe" },
{ "type": "post", "id": 2, "title": "Hello" }
]
}
Modele isto com uma união discriminada:
interface UserResult {
type: 'user';
id: number;
username: string;
}
interface PostResult {
type: 'post';
id: number;
title: string;
}
type SearchResult = UserResult | PostResult;
interface SearchResponse {
results: SearchResult[];
}
O campo type actua como discriminante. O TypeScript restringe o tipo automaticamente quando o verifica:
function renderResult(result: SearchResult) {
if (result.type === 'user') {
console.log(result.username); // O TypeScript sabe que isto é UserResult
} else {
console.log(result.title); // O TypeScript sabe que isto é PostResult
}
}
Enums a partir de valores de string
Quando uma API devolve um conjunto fixo de valores de string, utilize uniões de literais de string em vez de enums TypeScript:
{ "status": "active" }
// Valores possíveis: "active", "inactive", "suspended"
type UserStatus = 'active' | 'inactive' | 'suspended';
interface User {
id: number;
status: UserStatus;
}
As uniões de literais de string são mais simples, passíveis de tree-shaking e não geram JavaScript em tempo de execução como o enum faz. Reserve o enum para casos em que necessita de mapeamento inverso (número para nome).
Interfaces vs aliases de tipo
Tanto interface como type podem descrever formas de objectos. Eis quando escolher cada um:
Utilize interface para formas de objectos que possa estender:
interface BaseEntity {
id: number;
createdAt: string;
updatedAt: string;
}
interface User extends BaseEntity {
username: string;
email: string;
}
Utilize type para uniões, intersecções e tipos computados:
type ApiResponse<T> = {
data: T;
meta: { total: number; page: number };
};
type UserOrAdmin = User | Admin;
Na prática, qualquer um funciona para a maioria dos casos. Escolha uma convenção para o seu projecto e mantenha-a. Muitas equipas utilizam interface para modelos de dados e type para tudo o resto.
Utilizar ferramentas automatizadas
Tipar interfaces manualmente funciona para APIs pequenas, mas torna-se tedioso quando lida com endpoints que devolvem mais de 50 campos ou estruturas profundamente aninhadas.
Antes de converter JSON em tipos, certifique-se de que o seu JSON é válido e está bem formatado. O nosso formatador de JSON limpa respostas de API confusas, e o validador de JSON apanha erros de sintaxe antes de desperdiçarem o seu tempo.
Conversores online
Ferramentas como quicktype, json2ts e conversores de JSON para TypeScript permitem-lhe colar um payload JSON e obter interfaces de volta instantaneamente. Tratam de objectos aninhados, arrays e campos opcionais automaticamente.
O fluxo de trabalho típico:
- Chame a sua API e copie a resposta
- Cole o JSON no conversor
- Reveja os tipos gerados e ajuste os nomes
- Copie o TypeScript para a sua base de código
Geração de código a partir de especificações OpenAPI
Se a sua API tem uma especificação OpenAPI (Swagger), salte a conversão de JSON por completo. Ferramentas como openapi-typescript geram tipos directamente a partir da especificação:
npx openapi-typescript https://api.example.com/openapi.json -o ./src/types/api.ts
Esta abordagem é superior porque a especificação define campos opcionais, tipos anuláveis e enums explicitamente. Obtém tipos precisos sem adivinhar a partir de dados de amostra.
Validação em tempo de execução com Zod
Para máxima segurança, defina as suas formas com Zod e infira os tipos TypeScript a partir delas:
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
username: z.string(),
email: z.string().email(),
isActive: z.boolean(),
bio: z.string().nullable().optional(),
});
type User = z.infer<typeof UserSchema>;
// Agora valide em tempo de execução
const response = await fetch('/api/users/42');
const data = await response.json();
const user = UserSchema.parse(data); // Lança erro se a forma não corresponder
Com o Zod, os seus tipos e a lógica de validação vivem no mesmo local. Se a API mudar de forma, o parse() lança erro imediatamente em vez de deixar dados incorrectos propagarem-se pela sua aplicação.
Integrar tipos com chamadas de API
Os tipos só são úteis se os aplicar na fronteira onde os dados entram na sua aplicação.
Wrapper de fetch tipado
Crie uma função fetch genérica que imponha tipos:
async function fetchJson<T>(url: string, schema?: z.ZodType<T>): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (schema) {
return schema.parse(data);
}
return data as T;
}
// Utilização
const user = await fetchJson<User>('/api/users/42');
const validatedUser = await fetchJson('/api/users/42', UserSchema);
Axios com tipos
Se utiliza o Axios, aplique tipos ao genérico da resposta:
import axios from 'axios';
interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
perPage: number;
lastPage: number;
};
}
async function getUsers(page: number = 1) {
const response = await axios.get<PaginatedResponse<User>>('/api/users', {
params: { page },
});
return response.data; // Totalmente tipado
}
React Query / TanStack Query
Em aplicações React, combine tipos com a sua biblioteca de obtenção de dados:
import { useQuery } from '@tanstack/react-query';
function useUser(id: number) {
return useQuery({
queryKey: ['user', id],
queryFn: () => fetchJson<User>(`/api/users/${id}`),
});
}
// No seu componente
const { data: user, isLoading } = useUser(42);
// user é tipado como User | undefined
Melhores práticas
Activar verificações estritas de null
No seu tsconfig.json, active o modo strict (ou, no mínimo, strictNullChecks). Isto obriga-o a lidar com null e undefined explicitamente:
{
"compilerOptions": {
"strict": true
}
}
Sem verificações estritas de null, o TypeScript deixa-o aceder a .username num valor potencialmente null sem reclamar. Isso derrota o propósito de tipar as suas respostas de API.
Utilizar readonly para dados imutáveis
As respostas de API são dados que recebeu, não dados que deve mutar. Marque as propriedades como readonly:
interface User {
readonly id: number;
readonly username: string;
readonly email: string;
readonly createdAt: string;
}
Ou utilize um tipo utilitário para toda a interface:
type ImmutableUser = Readonly<User>;
Isto previne mutações acidentais que causam erros subtis.
Tipos com marca para IDs
Previna a mistura de IDs de diferentes entidades com tipos com marca:
type UserId = number & { readonly __brand: 'UserId' };
type PostId = number & { readonly __brand: 'PostId' };
function getUser(id: UserId): Promise<User> { /* ... */ }
function getPost(id: PostId): Promise<Post> { /* ... */ }
const userId = 42 as UserId;
const postId = 7 as PostId;
getUser(userId); // OK
getUser(postId); // Erro de tipo! Não pode passar PostId onde UserId é esperado
Centralizar os seus tipos
Mantenha todos os tipos relacionados com a API num directório dedicado:
src/
types/
api/
user.ts
post.ts
common.ts // Tipos partilhados como PaginatedResponse
index.ts // Re-exportações
Isto torna os tipos fáceis de encontrar, importar e manter. Quando uma API muda, actualiza um ficheiro e o compilador mostra-lhe tudo o que falha.
Validar na fronteira
Nunca confie em dados externos. Mesmo com tipos perfeitos, a API pode devolver algo inesperado. Valide as respostas onde elas entram na sua aplicação:
// Não faça isto
const user = (await response.json()) as User; // Confia cegamente
// Faça isto
const data = await response.json();
const user = UserSchema.parse(data); // Valida a forma
As asserções de tipo (as User) silenciam o compilador mas não verificam os dados. A validação em tempo de execução (Zod, io-ts, Valibot) verifica de facto se a forma corresponde.
Trabalhar com estruturas JSON complexas
Ao lidar com payloads JSON grandes ou complexos, utilize o editor de JSON para explorar e compreender a estrutura antes de escrever tipos. É muito mais fácil ver relações aninhadas num editor adequado do que em texto simples.
Para equipas que trabalham com APIs JSON, consulte estes guias relacionados:
- Melhores práticas de formatação JSON — mantenha o seu JSON limpo e legível
- Guia de validação com JSON Schema — valide contratos de API com JSON Schema
- Padrões de design de APIs JSON — conceba APIs fáceis de tipar
- Como validar JSON — apanhe payloads malformados cedo
Juntar tudo
Eis um exemplo completo que liga tudo — de JSON em bruto a dados validados e tipados num componente React:
import { z } from 'zod';
// 1. Definir o schema (fonte única de verdade)
const UserSchema = z.object({
id: z.number(),
username: z.string(),
email: z.string().email(),
isActive: z.boolean(),
role: z.enum(['admin', 'editor', 'viewer']),
profile: z.object({
bio: z.string().nullable(),
avatarUrl: z.string().url().nullable(),
socialLinks: z.array(z.object({
platform: z.string(),
url: z.string().url(),
})).optional(),
}),
});
// 2. Inferir o tipo a partir do schema
type User = z.infer<typeof UserSchema>;
// 3. Criar uma função fetch tipada
async function getUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`Failed to fetch user ${id}`);
const data = await response.json();
return UserSchema.parse(data);
}
// 4. Utilizar na sua aplicação com total segurança de tipos
const user = await getUser(42);
console.log(user.profile.bio); // string | null — o compilador sabe
console.log(user.profile.socialLinks); // array ou undefined — o compilador sabe
Sem any. Sem adivinhações. Sem surpresas em tempo de execução.
Conclusão
Converter JSON em tipos TypeScript não é apenas para agradar ao compilador. Trata-se de construir uma rede de segurança que apanha erros cedo, alimenta o autocomplete do seu editor e torna a sua base de código mais fácil de manter.
Comece de forma simples: copie uma resposta JSON, escreva a interface manualmente e tipe as suas chamadas fetch. À medida que o seu projecto cresce, adopte o Zod para validação em tempo de execução e a geração de código OpenAPI para APIs grandes. O investimento inicial paga-se na primeira vez que um erro de tipo apanha um bug que teria chegado a produção.