Durante muito tempo eu tive uma relação meio estranha com TypeScript no frontend.
Eu gosto do autocomplete. Gosto da segurança em refatoração. Gosto de modelar regra de negócio.
Mas tem uma parte específica que sempre me incomodou: tipar resposta de request.
Era uma das coisas que eu mais gostava de fazer no começo. Parecia organizado, forte, profissional.
Só que com o tempo começou a pesar.
Cada mudança na API espalhava impacto.
Os tipos começavam a crescer.
null, undefined, union estranho, campo opcional que não deveria ser opcional.
E aos poucos isso começou a dificultar manutenção e mais atrapalhar do que ajudar.
Teve momento em que eu simplesmente fazia:
const { data } = await axios.get<any>("/users/1")
E saía usando direto no HTML.
Porque parecia mais simples. Mais rápido. Menos burocrático.
Mas claramente isso também não era a solução.
Foi quando eu comecei a refletir melhor sobre o que eu realmente gostava e o que eu não gostava do TypeScript no frontend.
No backend eu adoro usar DTO.
Mas no frontend eu quase nunca via alguém falar sobre isso com a mesma naturalidade.
Pode parecer óbvio agora, mas na época não era tão claro pra mim.
Então resolvi experimentar trazer essa ideia pro front.
Eu fazia algo assim:
type UserApi = {
id: number
first_name: string
last_name: string
is_active: boolean
}
const { data } = await axios.get<UserApi>("/users/1")
Parece certo.
Mas na prática eu estava fazendo um acoplamento direto.
Se a API mudasse first_name para firstName, metade do projeto quebrava.
E o pior: os componentes começavam a conhecer detalhes que não pertencem ao domínio da aplicação:
snake_case- flags
0 | 1 - campo opcional vindo como
null - string suja
- fallback espalhado no template
E aí a tipagem começava a ficar mais pesada.
Não porque TypeScript é ruim.
Mas porque eu estava usando ele para modelar contrato externo, não domínio.
Usando DTO
O que resolveu para mim foi separar claramente:
- O formato da API
- O formato que minha aplicação realmente usa
E fazer essa transformação em um único lugar.
Exemplo:
type UserApi = {
id?: number
first_name?: string | null
last_name?: string | null
is_active?: boolean | 0 | 1 | null
}
export class UserDTO {
private constructor(
public readonly id: number,
public readonly fullName: string,
public readonly isActive: boolean,
) {}
static fromJson(json: UserApi): UserDTO {
const first = (json.first_name ?? "").trim()
const last = (json.last_name ?? "").trim()
const isActive =
typeof json.is_active === "boolean"
? json.is_active
: json.is_active === 1
return new UserDTO(
json.id ?? 0,
`${first} ${last}`.trim() || "Sem nome",
isActive,
)
}
get initials(): string {
return this.fullName
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]!.toUpperCase())
.join("")
}
}
Aqui acontece algo importante:
- A API pode mudar.
- Pode mandar
null. - Pode mandar
0 | 1. - Pode faltar campo.
Mas o resto da aplicação nunca vê isso.
Assim, se a API mudar, eu só preciso atualizar o DTO em vez de vários arquivos espalhados pelo projeto.
TypeScript não valida em tempo de execução
Tem um ponto que muita gente esquece:
TypeScript só existe em tempo de compilação.
Em execução, ele não está lá.
Se você controla totalmente o backend, isso normalmente não é um problema tão grande. Mas quando você consome API de terceiros, a história muda.
Imagine que você integra com um gateway de pagamento ou um serviço externo qualquer.
Você tipa assim:
type PaymentApi = {
status: "approved" | "rejected"
amount: number
currency: string
}
E usa normalmente:
const { data } = await axios.get<PaymentApi>("/external/payment/123")
if (data.status === "approved") {
liberarPedido()
}
Compila. Parece seguro.
Mas em produção o serviço externo resolve mudar silenciosamente:
{
"status": "APPROVED",
"amount": "199.90",
"currency": "BRL"
}
Agora você tem três problemas:
statusnão bate com o unionamountvirou string- nada disso foi avisado pelo TypeScript
O código continua compilando.
O erro só aparece no comportamento:
- pedido não é liberado
- comparação falha
- lógica começa a agir de forma errada
E pior: às vezes nem gera erro. Só começa a se comportar errado.
Esse é o tipo de bug mais perigoso.
É aqui que validação em execução começa a fazer sentido.
Um exemplo com Zod:
import { z } from "zod"
const PaymentApiSchema = z.object({
status: z.enum(["approved", "rejected"]),
amount: z.number(),
currency: z.string(),
})
type PaymentApi = z.infer<typeof PaymentApiSchema>
static fromApi(input: unknown): PaymentDTO {
const parsed = PaymentApiSchema.parse(input)
return new PaymentDTO(
parsed.status,
parsed.amount,
parsed.currency,
)
}
Se o serviço externo mudar amount para string ou status para "APPROVED", o erro acontece imediatamente em execução.
Ele falha cedo. De forma explícita.
Conclusão
No fim, é algo simples.
Separar o que vem da API do que a aplicação realmente usa deixa o código mais organizado e mais fácil de manter.
Quando algo muda, você ajusta em um único lugar em vez de alterar vários arquivos.