Um dos grandes problemas de linguagens dinamicamente tipadas é que não temos como garantir que o fluxo de dados vai ser sempre correto, uma vez que não temos como "forçar" que um parâmetro ou uma variável, por exemplo, não seja nulo. A saída padrão que utilizamos quando temos estes casos é o simples teste:
function foo (mustExist) {
if (!mustExist) throw new Error('Parameter cannot be null')
return ...
}
O grande problema nisso é a poluição do nosso código, pois temos que testar as variáveis em todos os lugares, e não há uma forma de garantir que todas as pessoas que estão desenvolvendo o código vão, de fato, realizar este teste em todos os lugares onde uma variável ou parâmetro não possa ser nulo, muitas vezes nem sabemos que tal parâmetro pode vir como undefined
ou null
, isto é muito comum quando temos times diferentes para backend e frontend, o que é a grande maioria dos casos.
Visando melhorar um pouco este cenário, comecei a pesquisar sobre como podemos minimizar os efeitos "inesperados" da melhor maneira e quais seriam as melhores estratégias para isto. Foi quando me deparei com este artigo incrível do Eric Elliott. A ideia aqui não é contradizer completamente o artigo dele, mas acrescentar algumas informações interessantes que acabei por descobrir com o tempo e experiência na área de desenvolvimento JavaScript.
Antes de começar, queria dar uma pincelada por alguns pontos que são discutidos por este artigo e dar a minha opinião pessoal como desenvolvedor backend, pois o foco deste artigo é mais o frontend.
A origem do problema
O problema de tratamento de dados pode ter várias fontes. A principal causa é, com certeza, o input do usuário. Porém, existem outras origens de dados mal formados, além das que foram citadas no artigo:
- Registros de bancos de dados
- Funções que retornam dados nulos implicitamente
- APIs externas
Vamos ter um tratamento diferente para cada tipo de caso que pegarmos e vamos passar por todos eles mais a frente, lembrando que nada é uma bala de prata. A grande parte destas origens vem de erros humanos, isto porque muitas vezes as linguagens estão preparadas para lidar com dados nulos ou não definidos, porém o fluxo de transformação destes dados pode não estar preparado para lidar com eles.
Inputs de usuário
Neste caso não temos muito como fugir, se o problema é o input de usuário, temos que lidar com ele através do que chamamos de Hydration (ou hidratação) do mesmo, ou seja, temos que pegar o input cru que o usuário nos envia, por exemplo, em um payload de uma api, e transformar ele em algo que possamos trabalhar sem erros.
No backend, quando estamos utilizando um webserver como o Express, podemos realizar todo o tratamento de inputs de usuário vindos do frontend através de padrões como o JSON Schema ou ferramentas como o Joi.
Um exemplo do que podemos fazer utilizando uma rota com Express e AJV seria o seguinte:
const Ajv = require('ajv')
const Express = require('express')
const bodyParser = require('body-parser')
const app = Express()
const ajv = new Ajv()
app.use(bodyParser.json())
app.get('/foo', (req, res) => {
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
password: { type: 'string' },
email: { type: 'string', format: 'email' }
},
additionalProperties: false
required: ['name', 'password', 'email']
}
const valid = ajv.validate(schema, req.body)
if (!valid) return res.status(422).json(ajv.errors)
// ...
})
app.listen(3000)
Veja que estamos validando o body de uma rota, obrigatoriamente o body é um objeto que vamos receber do body-parser
através de um payload, neste caso estamos passando o mesmo através de um JSON-Schema para que ele seja validado, se uma dessas propriedades tiver um tipo diferente ou um formato diferente (no caso do e-mail).
Importante: Veja que estamos retornando um código HTTP 422, que significa Unprocessable Entity. Muitas pessoas tratam um erro de request, como um body ou query string errados como um erro 400 Bad Request, o que não é totalmente errado, porém o problema neste caso não foi com a request em si, mas com os dados que o usuário mandou nela. Então a melhor resposta que podemos dar para um usuário é 422, informando que a request está certa, porém não pode ser processada porque está fora do que esperamos
Uma outra opção além do AJV é o uso de uma biblioteca que criei em conjunto com o Roz, que chamamos de Expresso, um conjunto de bibliotecas para facilitar e deixar mais rápido o desenvolvimento de APIs que utilizam o Express. Uma dessas ferramentas é o @expresso/validator que faz basicamente o que mostramos anteriormente, porém ele pode ser passado como um middleware.
Parâmetros opcionais com valores default
Além do que validamos anteriormente, abrimos possibilidade de que um valor nulo possa passar para dentro de nossa aplicação se ele não for enviado em um campo opcional. Por exemplo, imagine que temos uma rota de paginação que recebe dois parâmetros: page
e size
como query string. Mas eles não são obrigatórios e, se não recebidos, devem assumir um valor padrão.
O ideal é que tenhamos uma função em nosso controller que faça algo deste tipo:
function searchSomething (filter, page = 1, size = 10) {
// ...
}
Nota: Assim como o 422 que retornamos anteriormente, para consultas paginadas, é importante que retornemos o código correto, o 206 Partial Content. Sempre que tivermos uma request cuja quantidade de dados retornados seja somente uma parte de um todo, vamos retornar como 206, quando a última página for acessada pelo usuário e não houver mais dados além destes, podemos retornar 200 e, se o usuário tentar buscar uma página além do range total de páginas, retornamos um 204 No Content ou então (até melhor) um 416 Request Range Not Satisfiable.
Isso resolveria no caso de recebermos os dois valores em branco, porém é ai que entramos em um ponto bastante controverso do JavaScript no geral. Os parâmetros opcionais só obtém seu valor default se, e somente se, ele for vazio, porém isto não funciona para o null
, então se fizermos este teste:
function foo (a = 10) {
console.log(a)
}
foo(undefined) // 10
foo(20) // 20
foo(null) // null
Portanto, não podemos confiar somente aos parâmetros opcionais o tratamento de informações como null
. Então, para estes casos podemos fazer de duas formas:
- Tratamos diretamente no controller
function searchSomething (filter, page = 1, size = 10) {
if (!page) page = 1
if (!size) size = 10
// ...
}
O que não é muito bonito.
- Tratamos na rota, com JSON-Schema
Novamente podemos recorrer ao AJV ou ao @expresso/validator para poder tratar estes dados para nós
app.get('/foo', (req, res) => {
const schema = {
type: 'object',
properties: {
page: { type: 'number', default: 1 },
size: { type: 'number', default: 10 },
},
additionalProperties: false
}
const valid = ajv.validate(schema, req.params)
if (!valid) return res.status(422).json(ajv.errors)
// ...
})
Lidando com Null e Undefined
Eu, pessoalmente, não sou um grande fã dessa dialética que o JavaScript utiliza para mostrar que um valor está em branco, por vários motivos, além de ser mais complicado de abstrair estes conceitos, temos o caso dos parâmetros opcionais. Se você ainda tem dúvidas sobre os conceitos, uma grande explicação prática seria a imagem a seguir:
Uma vez que agora sabemos ao que cada definição se refere, uma grande adição ao JavaScript em 2020 será um conjunto de duas funcionalidades. O Null Coalescing Operator e o Optional Chaining. Não vou entrar em detalhes porque já escrevi um artigo sobre isto, mas estas duas adições vão facilitar muito pois vamos poder focar nos dois conceitos: null
e undefined
com um operador próprio, o ??
, ao invés de termos que utilizar as negações booleanas como !obj
, que são propensas a vários erros.
Funções implícitamente nulas
Este é um problema bem mais complexo de se resolver porque ele é justamente implícito. Algumas funções tratam dados assumindo que os mesmos sempre serão preenchidos, porém em alguns casos isto pode não ser verdade, vamos pegar um exemplo clássico:
function foo (num) {
return 23*num
}
Se num
for null
, o resultado desta função será 0. O que pode não ser esperado. Nestes casos não temos muito o que fazer a não ser testar o código. Podemos realizar duas formas de teste, a primeira seria o simples if
:
function foo (num) {
if (!num) throw new Error('Error')
return 23*num
}
A segunda forma seria utilizar um Monad chamado Either, que foi explicado no artigo que citei, e é uma ótima forma de tratar dados ambíguos, ou seja, que podem ser nulos ou não. Isto porque o JavaScript já possui um nativo que suporta dois fluxos de ação, a Promise.
function exists (value) {
return x != null ? Promise.resolve(value) : Promise.reject(`Invalid value: ${value}`)
}
async function foo (num) {
return exists(num).then(v => 23 * v)
}
Desta forma podemos delegar o catch
de exists
para a função que chamou a função foo
:
function init (n) {
foo(n)
.then(console.log)
.catch(console.error)
}
init(12) // 276
init(null) // Invalid value: null
Registros de Bancos de Dados e APIs Externas
Este é um caso bastante comum principalmente quando temos sistemas que foram desenvolvidos em cima de bancos de dados já previamente criados e populados. Por exemplo, um novo produto que utiliza a mesma base de um produto de sucesso anterior, integrações de usuários entre sistemas diferentes e por ai vai.
O grande problema aqui não é o fato de que o banco é desconhecido, na verdade essa é a causa, como não sabemos o que foi feito no banco, não temos como atestar se o dado vai ou não vai vir nulo ou indefinido. Um outro caso é o de má documentação, onde o banco de dados não é documentado de forma satisfatória e acabamos com o mesmo problema anterior.
Não há muito o que fugir neste caso, eu, pessoalmente, prefiro testar se o dado está de uma forma que eu não poderei utilizar. Porém não é bom fazer isso com todos os dados, uma vez que muitos objetos retornados podem ser simplesmente grandes demais. Então é sempre uma boa prática verificar se o dado sob o qual você está realizando alguma função, por exemplo, um map
ou filter
está ou não indefinido antes de realizar a operação.
Retornando erros
É uma boa prática ter o que chamamos de Assertion Functions para bancos de dados e também para APIs externas, basicamente estas funções retornam o dado, se o mesmo existir, ou então estouram um erro quando o dado não existe. O caso mais comum deste uso é quando temos uma API para, por exemplo, buscar algum tipo de dado por um ID, o famoso findById
.
async function findById (id) {
if (!id) throw new InvalidIDError(id)
const result = await entityRepository.findById(id)
if (!result) throw new EntityNotFoundError(id)
return result
}
Substitua
Entity
pelo nome da sua entidade, por exemplo,UserNotFoundError
.
Isto é bom porque podemos, dentro de um mesmo controller, ter uma função, por exemplo, para encontrar um usuário por ID, e outra função que utiliza-se de um usuário para buscar outro dado, digamos, os perfis deste usuário em outra base. Quando chamarmos a função de busca de perfis, vamos fazer uma asserção para garantir que o usuário realmente existe no banco, caso contrário a função nem será executada e poderemos buscar o erro diretamente na rota.
async function findUser (id) {
if (!id) throw new InvalidIDError(id)
const result = await userRepository.findById(id)
if (!result) throw new UserNotFoundError(id)
return result
}
async function findUserProfiles (userId) {
const user = await findUser(userId)
const profile = await profileRepository.findById(user.profileId)
if (!profile) throw new ProfileNotFoundError(user.profileId)
return profile
}
Veja que não executaremos uma chamada no banco de dados se o usuário não existir, porque a primeira função garante sua existência. Agora na rota podemos fazer algo do tipo:
app.get('/users/{id}/profiles', handler)
// --- //
async function handler (req, res) {
try {
const userId = req.params.id
const profile = await userService.getProfile(userId)
return res.status(200).json(profile)
} catch (e) {
if (e instanceof UserNotFoundError || e instanceof ProfileNotFoundError) return res.status(404).json(e.message)
if (e instanceof InvalidIDError) return res.status(400).json(e.message)
}
}
Podemos saber qual tipo de erro retornar somente com o nome da instancia da classe de erro que temos.
Conclusão
Existem diversas formas de podermos tratar os nossos dados para que tenhamos um fluxo contínuo e previsível de informações. Você conhece alguma outra dica?! Deixa ela aqui nos comentários :D
Não deixe de acompanhar mais do meu conteúdo no meu blog e se inscreva na newsletter para receber notícias semanais!