Um dos erros mais comuns no JS, principalmente entre iniciantes, seria o famoso:
Uncaught TypeError: Cannot read property 'value' of null
Com outra variação aparecendo undefined
no lugar de null
, prova de como esse é um erro comum seria essa pergunta no stack overflow, o maior fórum de programação do mundo, com mais de 891 mil views ao longo de 9 anos.
Este problema ocorre ao tentar acessar um membro de um objeto, onde ele seria na verdade um valor nulo (null
) ou indefinido (undefined
). Aqui um exemplo:
const obj = { property: 'value' };
obj.property;
Onde se nós mudarmos o valor de obj
para null
ou undefined
, o erro citado no início deste artigo irá acontecer. Ex:
let obj = null;
// TypeError: Cannot read property 'property' of null
obj.property;
//
obj = undefined;
// TypeError: Cannot read property 'property' of undefined
object.property;
E este não é exatamente um problema novo no mundo da programação, sendo até mesmo chamado de o erro de 1 bilão de dólares, por isso hoje iremos explorar formas de lidar com esse problema no JS.
# 1 Programação defensiva
A solução mais simples para isso seria validar se o valor é null ou undefined antes de usar esse valor (inclusive se você utilizar TypeScript, isso pode ajudar ele a inferir um novo tipo para a sua variável dentro do if agora que ele tem certeza que ela existe), ex:
const h1 = document.querySelector("h1");
if (h1) {
h1.innerHTML = "Existo por aqui";
}
Ou se realmente quisermos verificar por null e undefined:
let acceptBool = false;
if (acceptBool !== undefined && acceptBool !== null) {
console.log("false não é undefined e nem null")
}
A forma abreviada é útil quando você sabe que o valor que você vai validar não pode ser um valor falsy (que pode ser convertido em false, por exemplo uma string vazia ou 0 ou o próprio false), assim você garante que só vai cair na sua validação caso esse valor realmente não exista.
Para poder lidar com esses casos onde a forma abreviada não se aplica, podemos verificar diretamente se o valor é undefined ou null, porém assim como se pode notar no segundo exemplo, isso é o código mais legível do mundo, fora que existem diversas ocasiões onde precisamos lidar com esses tipos de valores, então uma forma de melhorar essa solução poderia ser tendo uma função que abstrai essa verificação, ex:
const isNone = (value) => value === undefined || value === null;
# 2 Usando operadores do JS
Por ser um problema tão comum o JS tem algumas funcionalidades nativas para lidar com alguns desses cenários, por exemplo o null coalescing operator (??), que nós podemos utilizar para prover um valor padrão caso o valor desejado seja null ou undefined:
const valorOrNone = null;
const value = valueOrNone ?? "Valor padrão";
Porém, apesar de muito útil, caso estivermos lidando com um objeto com outros objetos possivelmente nulos dentro dele, isso pode rapidamente se tornar um pouco confuso:
function showDeepProperty(obj) {
const deepProperty = obj.a.b.c;
console.log(deepProperty);
}
showDeepProperty({
a: {
b: {
c: "propriedade profunda"
}
}
});
// TypeError: Cannot read property 'b' of undefined
showDeepProperty({
a: null
});
Para corrigir isso teríamos que usar o operador de coalescencia nula para cada propriedade sendo acessada, ex:
function showDeepProperty(obj) {
const a = obj.a ?? { b: c: '' };
const b = a.b ?? { c: '' };
const deepProperty = b.c ?? '';
console.log(deepProperty);
}
Por isso podemos utilizar um outro operador do JS para tornar esse código bem legível e conciso novamente, o optional chaining operator, ex:
function showDeepProperty(obj) {
const deepProperty = obj?.a?.b?.c;
console.log(deepProperty);
}
Ao utilizar ?.
antes de acessar uma propriedade ele vai acessar o valor dela se existir, entretanto se o valor for null ou undefined ele retornaria undefined, sendo a inovação aqui que ele além de não lançar um erro, permite que você vá encadeando (por isso o chaining no nome) esse operador para ir acessando camadas mais profundas desse objeto, e se em qualquer momento alguma dessas propriedades não existir ou cair no critério dito anteriormente, ele vai simplesmente retornar undefined ao invés de um erro.
Com isso, podemos combinar os dois e também definir um valor padrão ao final do encadeamento:
function showDeepProperty(obj) {
const deepProperty = obj?.a?.b?.c ?? '';
console.log(deepProperty);
}
Sendo isso o equivalente ao código mostrado que utilizava diversos operadores de coalescencia nula.
# 3 Null Object
Também podemos nos utilizar de técnicas dos paradigmas suportados pelo JS para resolver esse problema, por exemplo, ao utilizarmos as funcionalidades relacionadas a orientação a objetos presentes no JS podemos implementar um design pattern conhecido como Null Object.
O cenário onde podemos utilizar ele seriam os casos onde precisariamos retornar um valor nulo no código, e a solução seria retornar outro valor no lugar, afinal já vimos ao longe deste artigo como ele pode não ser o melhor valor para representar a não existencia de algo no código.
A ideia do Null Object para isso seria utilizar herança para ter uma classe que cria uma versão do objeto real, e outra nula, assim ambos vão ter os mesmos métodos, assim podemos confiar no polimorfismo para chamar os métodos do objeto real quando ele for retornado, e os métodos do objeto nulo no caso do valor não existir, e nesse caso os métodos do objeto nulo não fariam nada. Ex:
class Post {
constructor({ id, title, tags }) {
this.id = id;
this.title = title;
this.tags = tags;
}
match(tags) {
return tags.every(tag => this.tags.includes(tag));
}
ifMatch(tags, perform) {
if (this.match(tags)) {
perform();
}
return this;
}
}
class NullPost extends Post {
constructor() {
super({ id: 0, title: '', tags: [] });
}
match(_tags) {
return false;
}
ifMatch(_tags, _perform) {
return this;
}
}
class PostsGateway {
constructor(posts = []) {
this.posts = posts;
}
findById(id) {
return this.posts.find(post => post.id === id) ?? new NullPost();
}
}
const posts = new PostsGateway();
const post = posts.findById(1);
post.ifMatch(['oop', 'js'], () => {
console.log("Post is of OOP in JS");
});
Dessa forma, caso seja retornado um objeto Post real, ele vai executar a lógica correta e se as tags do artigo baterem, ele vai mostrar a mensagem no console, e caso seja retornado um null object, nada iria acontecer.
# 4 Optional Monad
Se você achou a ideia anterior interessante vai achar esta, que desta vez vai utilizar as capacidades de programação funcional do JS, muito útil também.
Na programação funcional um conceito que vem cada vez se tornando mais popular mesmo fora do paradigma são as monads, que são formas de representar computações dentro de um contexto, geralmente um efeito colateral, e compor essas computações, duas delas em específico vem aparecendo mesmo em linguagens tradicionalmente orientadas a objeto como a Optional, que vamos explorar hoje e representa computações em valores possívelmente nulos, e a Result, que representa computações que podem falhar. A propósito, as Promises do JS são inspiradas nesse conceito, sendo algo semelhante a uma Monad de Result assíncrona, muitas vezes conhecida como Task.
Dadas as explicações, show me the code:
const isNone = (value) => value === undefined || value === null;
const Optional = {
none: () => ({
map: (fn) => Optional.none(),
flatMap: (fn) => Optional.none(),
match: ({ none }) => none(),
isNone: () => true,
isSome: () => false
}),
some: (value) => ({
map: (fn) => Optional.some(fn(value)),
flatMap: (fn) => fn(value),
match: ({ some }) => some(value)
isNone: () => false,
isSome: () => true
}),
of: (value) => isNone(value) ? Optional.none() : Optional.some(value);
};
Optional
.of(1)
.map(x => x + 1)
.match({
some: (value) => console.log(value),
none: () => console.log("Valor não existe")
}); // 2
Optional
.of(null)
.map(x => x + 1)
.match({
some: (value) => console.log(value),
none: () => console.log("Valor não existe")
}); // Valor não existe
Nesse caso nós temos o Optional.of
para pegar um valor qualquer (mesmo null ou undefined) e envolver ele num Optional, em FP nós chamamos isso de lifting pois estamos elevando o valor a um dado contexto.
Assim, podemos utilizar o Optional.map
para aplicar funções no valor dentro do Optional, semelhante a como se utilizaria o método then
numa promise ou o map
em um array, e assim como no Null Object, se for um valor some
, então ele, de fato, vai aplicar a função callback no valor de dentro do Optional e fazer algo com ele, agora caso ele seja um none
, ai nada acontece, ele simplesmente devolve outro none
.
Podemos ver o Optional quase que como uma versão do Null Object, mas que funciona para qualquer valor, semelhante a como um Array funciona para qualquer valor e não precisamos sair por ai criando uma classe de Array para cada objeto existente no sistema só para fazer a mesma coisa todas as vezes.
O Optional.match
serve para quando precisarmos retirar o valor de dentro desse contexto, assim nós provemos uma função para cada possibilidade, seja o valor real ou nulo, e o match vai devolver como resultado o valor devolvido pela função correspondente a identidade atual do valor sendo o Optional.
Tudo isso é bem legal, mas ao utilizarmos o map, o valor é envolvido novamente num contexto, e por isso se nós retornarmos um Optional no map, terminariamos com um Optional de um Optional de um valor, sendo que o que nós queremos é só que ele seja um Optional de um valor, e para resolver essa situação, nós temos o Optional.flatMap
:
Optional
.of(document.querySelector("figure"))
.flatMap((figure) => Optional.of(figure.querySelector("figcaption")))
.match({
some(figcaption) {
figcaption.innerHTML = "Legenda";
},
none() {
console.log("Figure sem legenda");
}
})
Conclusão
Espero que o artigo de hoje tenha aberto sua mente para as várias técnicas que você pode usar para se livrar do null no seu código, e não se esqueça de compartilhar este artigo, até a próxima.
Links que podem te interessar
- Refactoring Guru sobre Null Object
- Null Coalescing Operator no MDN
- Optional Chaining no MDN
- Wikipedia sobre Monads na prática
- Wikipedia sobre Monads na Matemática
- Ótimo artigo sobre o Optional
Ass: Suporte Cansado.
Top comments (0)