Type-Safe Value Objects com TypeScript
Como o Typescript nos ajuda a evitar erros em tempo de compilação?
Este post é baseado em um artigo escrito Hannes Petri que é Software Engineer e compartilha seu conhecimento com a comunidade. O objetivo é abordar o problema que ele apresentou e a solução baseada em um conceito do Domain Driven Design (DDD). Isso vai nos ajudar a entender alguns problemas que podemos evitar ao desenvolver software.
O problema
No artigo de Hannes Petri, ele nos apresenta uma gráfica que utiliza um software comercial que foi escrito em Typescript. Em algum lugar na vasta base de código, há um punhado de linhas de código que criam um objeto BookCover
a partir de um objeto Book
e um objeto Author
:
const author = context.getAuthor();
const book = context.getBook();
const cover = new BookCover(
book.year,
book.title,
author.name,
);
Esta gráfica se concentra em obras de domínio público e, como sua próxima grande publicação, uma nova edição do clássico, The Jungle Book by Rudyard Kipling, será impressa em um grande número de cópias. Até agora tudo certo. Mas desde seu último lançamento, algumas modificações no código-fonte foram feitas no aplicativo, mas nada fora do comum.
No entanto, à medida que os livros chegam às livrarias, os leitores tem uma surpresa com a capa dos livros, a empresa percebe que cometeu um erro terrível…
Vemos claramente que na ilustração da capa do livro existe um erro bem grotesco. Os argumentos do construtor do BookCover se confundiram, e o nome do autor foi atribuído erroneamente como título do livro e vice-versa.
Como ambos os argumentos são do tipo string
, o compilador não detectou o erro.
Falta de atenção? Falta de testes unitários? Sim com certeza. Mas podemos utilizar de técnicas seguras que as linguagens de tipagem estática fornecem para detectar esses erros já em tempo de compilação.
A ideia foi explicitamente definida no livro de Eric Evans e é um conceito central em Domain-Driven Design.
No exemplo acima, se tivessem utilizado objetos de valor (Value Objects), teríamos o seguinte construtor:
class BookCover {
constructor(
year: Year, // era um number
authorName: Name, // era uma string
title: Title // era uma string
) {
// ...
}
// ...
}
Passar qualquer coisa além de um título para o título e um nome para o nome do autor impediria a compilação do aplicativo.
Implementando objetos de valor no TypeScript
Algumas perguntas podem surgir como: Quais são esses objetos de valor do ponto de vista técnico? Como devemos criá-los? Que operações podemos realizar deles? E como eles trazem segurança tipo?
Em 2004, quando a ideia de Value Objects foi concebida, aplicativos de grandes negócios geralmente significavam JAVA que normalmente significa utilizar classes.
O Typescript tem algumas semelhanças com JAVA e por isso podemos também utilizar classes para gerar valor aos nossos objetos. Mas a abordagem que Hannes Petri utiliza é diferente, envolvendo objetos simples, funções simples e interfaces parametrizadas. Começamos criando uma interface chamada ValueObject
:
interface ValueObject<T> {
type: string;
value: T;
}
Você pode usar o playground do Typescript para compilar e ver o código.
Definir um novo tipo de objeto de valor é apenas uma questão de estender a interface, substituindo (ou seja, restringindo) o tipo de type
com um identificador de string
exclusivo:
interface Name extends ValueObject<string> {
type: "NAME";
}
interface Title extends ValueObject<string> {
type: "TITLE";
}
interface Year extends ValueObject<number> {
type: "YEAR";
}
Mas deve estar pensando: A criação de um ValueObject como a se tornar bastante detalhado, como exemplo abaixo nos mostra:
const age: Year = { type: "YEAR", value: 1895 };
const name: Name = { type: "NAME", value: "Rudyard Kipling" };
Mas podemos contornar isso criando uma função:
function yearOf(value: number): Year {
return { type: "YEAR", value };
}
function nameOf(value: string): Name {
return { type: "NAME", value };
}
const year = yearOf(1895);
const name = nameOf("Rudyard Kipling");
Essas funções do criador também podem servir como um ponto de entrada para validação. Por exemplo, um ValueObject relacionado a Distancia pode não permitir números negativos.
Operações
Com o que foi proposto acima, o compilador não deixaria passar a mistura de argumentos que resultou em dezenas de caixas de livros que não poderiam ser vendidos. Para alavancar ainda mais os objetos de valor, uma série de funções para realizar várias operações sobre eles podem ser criadas de acordo com as necessidades da aplicação. Por exemplo, uma operação comum é determinar se dois valores são iguais:
function isEqualTo<T, V extends ValueObject<T>> (
v1: V,
v2: V
) {
return v1.value === v2.value;
}
Graças aos parâmetros de tipo, o compilador produzirá um erro sempre que a função for chamada em dois objetos de valor de tipo diferente. No código abaixo vou colocar a sequencia que você poderá testar no Playground do Typescript e depois o erro:
interface ValueObject<T> {
type: string;
value: T;
}
interface Name extends ValueObject<string> {
type: "NAME";
}
interface Title extends ValueObject<string> {
type: "TITLE";
}
interface Year extends ValueObject<number> {
type: "YEAR";
}
function yearOf(value: number): Year {
return { type: "YEAR", value };
}
function nameOf(value: string): Name {
return { type: "NAME", value };
}
function titleOf(value: string): Title {
return { type: "TITLE", value };
}
function isEqualTo<T, V extends ValueObject<T>>(
v1: V,
v2: V
) {
return v1.value === v2.value;
}
const title = titleOf("The Jungle Book");
const year = yearOf(1815)
const areEqual = isEqualTo(year, title);
O erro gerado quando passamos os valores dentro da função isEqualTo()
deve ser exatamente esse:
Se você já está familiarizado com objetos de valor, provavelmente deve saber da imutabilidade. Uma das principais vantagens dos Value Object
é sua estrutura imutável, que evita modificações acidentais e torna sua transmissão e cópia totalmente seguras. Felizmente, isso é facilmente alcançado com uma pequena alteração na Interface ValueObject
:
interface ValueObject<T>
extends Readonly<{
type: string;
value: T;
}> {}
Agora, se você tentar modificar o valor, verá esse erro:
Conclusão
O motivo de recompartilhar é mostrar a segurança que podemos ter na construção do software. No caso foi aplicado conceitos e recursos importantes para validar as regras e evitar bugs.
Se quiser saber mais sobre o assunto, recomendo essas leituras:
- Refatoração - Aperfeiçoando o Projeto de Código Existente - Martin Fowler
- Domain-Driven Design: Atacando as complexidades no coração do software - Eric Evans
- Blog de Martin Fowler --> martinfowler.com/bliki/ValueObject.html
Qualquer sugestão ou critica construtiva é sempre bem vinda! Obrigado por ler e até a próxima!