Práticas para projetos javascript robustos

Javascript é uma linguagem interessante - ou no mínimo diferente. Você talvez tenha uma visão de que é pura gambiarra, em especial se vem de algum ecossistema mais "consistente" como C#, ou Java.

Embora o autor desse texto seja certamente uma pessoa com um viés positivo, dado que alguns boletos são recorrentemente pagos utilizando essa linguagem, é fato que ela te permite fazer muitas coisas estranhas - o wtfjs tem uma lista bem completa. É tanta gambiarra que algo como jsfuck (perdão pelo francês) chega a ser possível.

Um grande colega de trabalho, ao qual me refiro como "o primeiro senior de verdade que conheci", sempre reclamou da falta de bons padrões centralizados. Ele veio do ecossistema C#, em que algo como a biblioteca polly é lugar comum. Uma outra crítica recorrente era destinada a vícios no fluxo de desenvolvimento bem característicos de quem utiliza a stack: console.log no lugar de um debugger decente; medo de utilizar design patterns corretamente, em especial Dependency Injection; desleixo com tratamento de exceptions. Isso pra citar alguns.

O Javascript das Ruas

Como um verdadeiro canivete suiço, existem coisas que apenas JS tem. A começar que a linguagem em si se tornou uma espécie de bytecode nativo para web com o tempo. O código em produção dessa página, por exemplo, é algo mais ou menos assim:

(self.webpackChunk_N_E = self.webpackChunk_N_E || []).push([
[8261], {
1421: (e, t) => {
"use strict";
Object.defineProperty(t, "__esModule", {
value: !0
}), ! function(e, t) {
for (var r in t) Object.defineProperty(e, r, {
enumerable: !0,
get: t[r]
})
}(t, {
getAppBuildId: function() {
return u
},
setAppBuildId: function() {
return n
}
});
let r = "";
// e assim continua, para o infinito e além

Mas qual seria a linguagem para humanos? A resposta hoje é simples, Typescript. Mas esse posto já teve outros concorrentes no passado: coffeescript, elm e flow, pra citar alguns.Talvez esse seja justamente o primeiro passo para ter um projeto minimamente pronto para produção: utilizar typescript.

Eu poderia incluir aqui uma série de debates que surgiram durante os anos - alguns bem filosóficos, que adentram a discussão entre tipagem estática vs dinâmica - porém vou recorrer a argumentos pragmáticos:

As melhores ferramentas, desde ORMs a Frameworks (e.g. TypeORM, Prisma, NestJS) são melhores e mais ergonômicas com Typescript.

A documentação do código se torna um subproduto.

Um caso de estudo sobre utilização de Typescript

Um exemplo prático bem interessante diz respeito a um dos temas básicos que precisam ser decididos em qualquer projeto: como realizar a validação de payload de entrada? Embora pareça algo simples, verificar de forma confiável se os dados estão corretos (seja tipo, valor, ou algo mais complexo) pode rapidamente virar um gargalo de performance ao incrementar a escala do problema.O clássico dos clássicos sempre foi utilizar o joi:

import { array, object, string } from "joi";

export const signUpData = object({
nome: string().required(),
email: string().email().required(),
senha: string().required(),
telefones: array().items(object({
numero: string().length(9).required(),
ddd: string().length(2).required(),
})),
});

export const signInCredentials = object({
email: string().email().required(),
senha: string().required(),
});

Se você já implementou o zod em alguma aplicação, certamente esse snippet é bem familiar - com efeito, ele se tornou um sucessor espiritual do joi com melhor suporte a tipagem. Essa abordagem realiza as validações em tempo de execução, em que a magia consiste em uma interface fluente agnóstica. O desenvolvedor define o que quer validar e isso funciona muito bem.

Mas sabe o que pode ser ainda melhor? Definir uma interface normal e a própria linguagem garantir o formato dos dados.

// usuario.ts
interface Usuario {
nome: string;
email: string & tags.Format<"email">;
senha: string;
//...
}

// server.ts
assert<Usuario>(
request.body // um objeto ainda não validado
);

E se apenas a ergonomia como argumento possa ser vista como algo purista (afinal, é apenas "syntax suggar"), talvez os benchmarks disponíveis na documentação do typia, que permite esse tipo de técnica, sejam mais impactantes:

O fato de ter um compilador na toolchain, com um intermediário entre o que você escreve e o que executa no browser (ou na v8, de forma mais geral), permite abordagens agressivas como Ahead Of Time (AOT) Compilation. O que isso significa? Que usando uma etapa intermediária, é possível extrair de uma interface typescript todos os metadados para validar o payload e gerar funções de validação inline muito mais otimizadas que algo genérico, como é o caso do zod/joi. Algo que o typia usa como principal truque. Isso pode ser estendido para outros casos relevantes, desde schemas graphql, até especificações OpenAPI - sem esquecer de coisas mais tradicionais, como docs funcionais das classes.

Um projeto com typescript, no mínimo, tem uma camada de metadados incrivelmente útil, seja para humanos (que vão ler um código mais explícito), ou para ferramentas (que podem fazer inferências e extrair contexto de forma mais robusta).

Considerando que Typescript é um ponto pacífico, a abordagem de ser um superset da linguagem original é uma via de mão dupla: enquanto os padrões ECMAScript são compatíveis (com alguns poucos casos em que essa regra não se mantem, a exemplo da implementação de decorators), a linguagem em si não blinda um projeto de ter diversos antipatterns clássicos.

Erros como dados

Uma das principais dificuldades é fugir de algo fortemente baseado em fluxos de try/catch. Não é difícil encontrar algo assim:

async execute(personId: string): Promise<ProfileEntity | null> {
try {
const profile = await this.repository.getByPersonId(personId);
if (!profile) throw new NotFoundProfileException('api.errors.profileNotFound')
return profile
} catch (error) {
if (!(error instanceof NotFoundProfileException)) {
throw new GetProfileException(`Error when try to get a person by personId: ${ personId }`);
}
throw error;
}
}

O principal problema é que diferente de linguagens como Java, em que é possível forçar o consumidor a validar quais exceptions um método pode lançar, no JS isso é uma verdadeira caixa de pandora.

Essa situação, especialmente quando o tipo de erro é algo conhecido (provavelmente pela familiaridade com a lib por parte de quem desenvolve), tende a levar ao uso de try/catch para gerenciar fluxos normais da aplicação. Uma forma de simplificar isso é justamente considerar que fluxos de erro conhecidos e tratáveis devem ser controlados por estruturas lógicas estruturadas, como if/else, o que leva naturalmente ao conceito de error as data - algo comum em GraphQL e outras linguagens como rust ou go.

Fluxos de erro conhecidos e tratáveis devem ser controlados por estruturas lógicas estruturadas, como if/else

Falando de forma mais prática, o seguinte código é mais simples de manter e descreve de forma mais direta a regra de tratamento do erro:

async execute(personId: string): Promise<Result<ProfileEntity>> {
try {
const profile = await this.repository.getByPersonId(personId);
const isValidProfile = profile instanceof ProfileEntity;
return isValidProfile
? Result.success<ProfileEntity>(profile)
: Result.fail<string>('api.errors.profileNotFound')
} catch (error) {
return Result.fail<string>('api.errors.profileApiException')
}
}

Um exemplo um pouco mais complicado

Em algo similar ao callback hell, estenda essa abordagem para quando multiplas chamadas são realizadas, sobretudo com fluxos alternativos em casos de erros específicos que podem ser recuperados. O seguinte código seria um exemplo com try/catch:

async getCalendar(personId: string): Promise<Calendar> {
try {
const profile = await this.repository.getByPersonId(personId);

if (!profile) {
throw new Error("MISSING_PROFILE");
}

let calendar: Calendar;
try {
const structure = await this.repository.getStructure(personId);
calendar = await this.repository.getCalendar(structure.code);
} catch (e) {
calendar = await this.repository.getGlobalCalendar();
}

return calendar;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);

switch (errorMessage) {
case "MISSING_PROFILE":
case "INVALID_PROFILE":
case "INVALID_STRUCTURE":
case "INVALID_CALENDAR":
throw error;
default:
const unknownError = new Error("UNKNOWN");
unknownError.cause = error;
throw unknownError;
}
}
}

Observe como a lógica tende a entrar e sair de blocos contextuais. Em especial, é difícil encadear operações de modo consistente e a utilização dessa função em um controller, por exemplo, seria igualmente complexa.

A mesma operação pode ser reescrita do seguinte modo:

type Reasons = "INVALID_PROFILE"
| "MISSING_PROFILE"
| "INVALID_STRUCTURE"
| "INVALID_CALENDAR"
| "UNKNOWN

async getCalendar(personId: string): Promise<
Result<Calendar, Failure<Reasons>>
> {
try {
const profile = await this.repository
.getByPersonId(personId);

if (profile.hasError || !profile.value) {
return Result.fail("INVALID_PROFILE", profile.error)
}

const structure = await this.repository
.getStructure(personId);

const calendar = structure.hasError
? this.repository.getGlobalCalendar()
: this.repository.getCalendar(structure.value.code);

if (calendar.hasError) {
return Result.fail(
"INVALID_CALENDAR",
calendar.error
)
}

return Result.ok(calendar.value);
} catch (error) {
return Result.fail("UNKNOWN", error)
}
}

Em que se torna mais simples entender como os erros conhecidos são tratados.

Muito embora o fluxo de desenvolvimento já seja melhor com uma simples classe de resultado, a solução de fato as vezes está em uma camada anterior. Pode ser o caso de tratar de forma mais estruturada questões de resiliência como um todo, aplicando padrões como retry, fallback, bulkhead, cache, ou circuit break.

Erros Transitórios

De forma geral, esses padrões resolvem um tipo de erro conhecido como transitórios (transient errors). A já citada polly é talvez o exemplo mais completo de uma abordagem flexível baseada em políticas de resiliência, que acabam sendo um misto desses padrões de projeto aplicados conforme for necessário para o problema específico.

Em algo que é relativamente comum (basta observar como nestjs/angular e spring boot são similares, por exemplo), quando um time de desenvolvedores migram de stack e implementam um análogo do que utilizavam na sua linguagem do coração, a cockatiel traz literalmente o mesmo para o ecossistema JS.

Policies na prática

A partir das funções de alto nível disponíveis na cockatiel, é possível encapsular lógicas customizadas e criar uma pipeline de resiliência a nível de aplicação:

import {
bulkhead,
circuitBreaker,
CircuitState,
ConsecutiveBreaker,
ExponentialBackoff,
handleAll,
retry,
} from "cockatiel";
import { env } from "./env";
import { getLogger } from "./logger";

const { resiliency } = env;

const logger = getLogger("resiliency");

export const circuitBreakerPolicy = circuitBreaker(handleAll, {
halfOpenAfter: resiliency.circuitBreaker.halfOpenAfter,
breaker: new ConsecutiveBreaker(
resiliency.circuitBreaker.maxConsecutiveFails,
),
});

circuitBreakerPolicy.onStateChange((state) =>
logger.info({ policy: "Circut Breaker", state: CircuitState[state] }),
);

export const retryPolicy = retry(handleAll, {
maxAttempts: resiliency.retry.maxAttempts,
backoff: new ExponentialBackoff({
maxDelay: resiliency.retry.maxDelay,
})
})

retryPolicy.onRetry(event => {
logger.warn({
policy: 'retry',
attempt: event.attempt,
delay: event.delay,
})
})

export const bulkheadPolicy = bulkhead(
resiliency.bulkhead.size,
resiliency.bulkhead.queue,
);

Em alguns como bulkhead, circuit breaker e retry, essa conguração acaba sendo global, porem outros padrões como fallback são mais comuns durante a requisição/operação local:

const fallbackPolicy = fallback(handleAll, async () => {
HttpClient.logger.info({
policy: "fallback",
fallbackUrl,
});

return fetch(url, payload).then((r) => {
this.config.validateStatus(r.status);
return r;
});
});

Por fim, a função wrap permite conectar todas as políticas:

const response = await wrap(
circuitBreakerPolicy,
bulkheadPolicy,
fallbackPolicy,
retryPolicy,
).execute(async () => {
if (bulkheadPolicy.executionSlots === 0) {
HttpClient.logger.warn({
policy: "bulkhead",
executionSlots: `${bulkheadPolicy.executionSlots}/${env.resiliency.bulkhead.size}`,
queueSlots: `${bulkheadPolicy.queueSlots}/${env.resiliency.bulkhead.queue}`,
});
}

return fetch(url, payload).then((r) => {
this.config.validateStatus(r.status);
return r;
});
});

return [
response.status, url, response.json() as Promise<TData>
];

Caso você tenha alguma necessidade em específico, por fim, é possível criar novas implementações a partir da classe Policy - um exemplo comum é uma política de cache, que administre toda a lógica de hit/miss e possa ser utilizada em cadeia com fallbacks.

Dito isso, tratar erros transitórios de forma intencional é evitar uma das clássicas falácias da computação distribuída: a rede é confiável. Certamente existirão momentos de indisponibilidade e confiar cegamente em recursos externos trará um efeito dominó mais cedo ou mais tarde.

Confiar cegamente em recursos externos trará um efeito dominó mais cedo ou mais tarde.

Um outro problema que precisa ser resolvido, diz respeito ao "erro de um milhão de dólares" de Tony Hoare: referências nulas.

Mas o que é null? É melhor ou pior que undefined?

A existência do tipo primitivo null é um caso em que a linguagem poderia ser chamada de Java Script, dado que foi inspirado no conceito de Java. Tem como significado "algo que foi iniciado com valor nenhum". Já undefined surge em uma evolução da linguagem, com a semantica de "algo que não foi inicializado ainda".

Em um nível fundamental, ter coisas que podem não existir é problemático - em alguns casos, como kotlin, isso é atacado frontalmente, de modo que o design da propria linguagem força o desenvolvedor a lidar com valores opcionais explicitamente. O fato de existirem duas formas de representar isso é um agravante adicional.

Se por um lado é importante evitar utilizar um dado sem verificar sua existência, por outro pode ser uma fonte de problemas. Uma análise interessante é o artigo The bloat of edge-case first libraries, do Jamer Garbutt, que analisa a forma com que uma cultura de criar código focado em edge cases leva a "micro-bibliotecas" como is-arrayish ou is-odd, gerando um problema generalizado de supply chain, o calcanhar de aquiles das comunidades Python e Javascript no que diz respeito a segurança.

Resta, então, o dilema: o que fazer? Criar uma cultura de validação extrema assumindo que em qualquer momento algo pode ser inexistente (possivelmente fazendo overengineering ou usando bibliotecas atômicas de terceiros), ou ter uma aplicação simples, legível, sem dependências e a uma linha do famoso can't access property "..." of undefined? A resposta está em dividir corretamente as responsabilidades.

Técnicas para lidar com valores nulos

O primeiro passo é mais simples: escolha uma das formas e limite o uso da outra, seja com linter, formatadores ou apenas convenções. Ter um tipo de valor vazio já traz problemas o suficiente, manter uma duplicidade trará ainda mais confusão.

Além disso, não tente acessar propriedades, métodos e índices de variáveis não validadas. Utilize optional chaining (.?). Se fizer sentido um fallback (e não seja necessário algo estruturado, como já mencionado na seção em que falamos sobre cockatiel), nullish coallescing (??) é uma forma consistente e evita problemas envolvendo booleanos, algo comum ao utilizar um operador lógico ou (||).

O exemplo abaixo certamente é teórico, porém serve para ilustrar como esses operadores são poderosos. Em especial, mostra como o optional chaining pode ser utilizando também para métodos e índices de arrays.

const data = maybe?.get(0)
?? maybe?.[0]
?? maybe?.first
?? { message: "not found" }

Embora seja possível realizar esses tratamentos, inclua uma camada de validação bem estruturada e utilize valores imutáveis sempre que possível, para garantir que a confiabilidade dessa verificação não é perdida durante a execução. A keyword const e tipos auxiliares como Readonly evitam que o valor possa ser "zerado" de formas implícitas pelo código.

A melhor forma de lidar com valores nullable é evitar que se propaguem: ter uma camada de validação bem estruturada e tratar dados confiáveis como imutáveis.

Uma base estável

Em resumo, se for necessário deixar uma mensagem a partir dessa discussão, lembre-se do seguinte:

Um projeto com typescript, no mínimo, tem uma camada de metadados incrivelmente útil, seja para humanos (que vão ler um código mais explícito), ou para ferramentas (que podem fazer inferências e extrair contexto de forma mais robusta).

Fluxos de erro conhecidos e tratáveis devem ser controlados por estruturas lógicas estruturadas, como if/else;

Confiar cegamente em recursos externos trará um efeito dominó mais cedo ou mais tarde;

A melhor forma de lidar com valores nullable é ter uma camada de validação bem estruturada e tratar dados confiáveis como imutáveis.

Times que resolvam esses problemas terão eliminado uma fonte recorrente de instabilidade dentro da stack que domina a web. Além disso, adicionar uma suíte de testes robusta e escolher corretamente padrões de projeto que se encaixam no contexto podem ser citados, uma vez que merecem artigos dedicados.