Getters e setters são recursos poderosos do JavaScript que permitem controlar o acesso a propriedades de um objeto. Eles são métodos especiais que são chamados quando você lê (get) ou escreve (set) uma propriedade, permitindo executar lógica adicional, validar dados ou calcular valores dinamicamente. Nesta aula, vamos explorar como definir acessores em objetos literais e em classes, além de usar Object.defineProperty para propriedades computadas mais avançadas.

Esses conceitos são fundamentais para criar código mais limpo, seguro e expressivo, especialmente em aplicações que envolvem modelagem de dados, validação de entrada e encapsulamento. Vamos mergulhar nos detalhes e ver exemplos práticos que você pode aplicar no seu dia a dia.

Definindo acessores

Em JavaScript, existem duas maneiras principais de definir getters e setters: em objetos literais e em classes. Ambas usam as palavras-chave get e set.

Em um objeto literal, você pode definir um getter e um setter para uma propriedade, como no exemplo abaixo. O getter é uma função que retorna o valor da propriedade, e o setter é uma função que recebe o novo valor e pode processá-lo antes de armazená-lo.

const pessoa = {
  _nome: 'João',
  get nome() {
    return this._nome;
  },
  set nome(novoNome) {
    if (novoNome.length > 0) {
      this._nome = novoNome;
    } else {
      console.error('Nome inválido');
    }
  }
};

console.log(pessoa.nome); // João
pessoa.nome = 'Maria';
console.log(pessoa.nome); // Maria
pessoa.nome = ''; // Nome inválido

No exemplo, usamos uma propriedade interna _nome (com underscore) para armazenar o valor real, enquanto o getter e o setter controlam o acesso. Isso é uma convenção comum para evitar conflitos de nomenclatura.

Em classes, a sintaxe é semelhante. Vamos criar uma classe ContaBancaria com um getter e setter para o saldo, adicionando validação:

class ContaBancaria {
  constructor(titular) {
    this._titular = titular;
    this._saldo = 0;
  }

  get saldo() {
    return this._saldo;
  }

  set saldo(valor) {
    if (valor < 0) {
      throw new Error('Saldo não pode ser negativo');
    }
    this._saldo = valor;
  }

  depositar(valor) {
    this._saldo += valor;
  }

  sacar(valor) {
    if (valor > this._saldo) {
      throw new Error('Saldo insuficiente');
    }
    this._saldo -= valor;
  }
}

const conta = new ContaBancaria('Ana');
conta.depositar(100);
console.log(conta.saldo); // 100
conta.saldo = 200;
console.log(conta.saldo); // 200

Observe que o setter valida se o valor é negativo, lançando uma exceção. Isso garante que o saldo nunca fique inválido, mesmo se alguém tentar atribuir diretamente.

Object.defineProperty

Outra forma de definir getters e setters é usando o método estático Object.defineProperty. Esse método permite definir propriedades com descritores, incluindo get e set, e é útil quando você precisa criar propriedades dinamicamente ou em objetos que não são literais.

A sintaxe é: Object.defineProperty(obj, prop, descriptor), onde descriptor é um objeto que pode conter get, set, enumerable, configurable, entre outros. Veja um exemplo:

const produto = {};

Object.defineProperty(produto, 'preco', {
  get() {
    return this._preco;
  },
  set(valor) {
    if (valor < 0) {
      throw new Error('Preço inválido');
    }
    this._preco = valor;
  },
  enumerable: true,
  configurable: true
});

produto.preco = 19.99;
console.log(produto.preco); // 19.99
produto.preco = -5; // Erro: Preço inválido

Uma vantagem de Object.defineProperty é que você pode definir várias propriedades de uma vez usando Object.defineProperties. Também é possível definir propriedades em objetos que já existem, o que é útil para adicionar comportamentos a objetos de bibliotecas ou dados vindos de APIs.

Outra aplicação é criar propriedades computadas, que calculam um valor a partir de outras propriedades. Por exemplo:

const pessoa = {};
Object.defineProperties(pessoa, {
  primeiroNome: { value: 'João', writable: true },
  sobrenome: { value: 'Silva', writable: true },
  nomeCompleto: {
    get() {
      return `${this.primeiroNome} ${this.sobrenome}`;
    }
  }
});

console.log(pessoa.nomeCompleto); // João Silva
pessoa.primeiroNome = 'Maria';
console.log(pessoa.nomeCompleto); // Maria Silva

Nesse caso, nomeCompleto é uma propriedade computada que depende de primeiroNome e sobrenome. Ela não armazena um valor próprio, mas calcula na hora do acesso.

Casos de uso

Getters e setters são amplamente utilizados em situações que exigem controle sobre os dados. Vamos ver alguns casos comuns:

  • Validação de dados: como nos exemplos acima, os setters podem validar valores antes de armazená-los, garantindo que o objeto permaneça em um estado consistente.
  • Encapsulamento: você pode expor uma interface pública enquanto mantém detalhes de implementação privados, usando propriedades internas com underscore ou símbolos.
  • Cálculos automáticos: propriedades computadas, como área de um círculo ou nome completo, podem ser calculadas dinamicamente, evitando armazenamento redundante.
  • Log e auditoria: os setters podem registrar alterações, por exemplo, para fins de depuração ou histórico.
  • Compatibilidade: em bibliotecas, getters e setters podem ser usados para migrar de propriedades simples para propriedades com lógica sem quebrar o código do usuário.

Abaixo, um exemplo mais complexo que combina validação e cálculo:

class Retangulo {
  constructor(largura, altura) {
    this._largura = 0;
    this._altura = 0;
    this.largura = largura;
    this.altura = altura;
  }

  get largura() {
    return this._largura;
  }

  set largura(valor) {
    if (valor <= 0) {
      throw new Error('Largura deve ser positiva');
    }
    this._largura = valor;
  }

  get altura() {
    return this._altura;
  }

  set altura(valor) {
    if (valor <= 0) {
      throw new Error('Altura deve ser positiva');
    }
    this._altura = valor;
  }

  get area() {
    return this._largura * this._altura;
  }
}

const ret = new Retangulo(5, 10);
console.log(ret.area); // 50
ret.largura = 7;
console.log(ret.area); // 70

Esse exemplo mostra como os getters podem ser usados para expor propriedades calculadas (área) sem a necessidade de armazená-las, mantendo o objeto enxuto.

Boas práticas

  • Use _ como prefixo em propriedades internas para indicar que não devem ser acessadas diretamente.
  • Prefira getters e setters em classes para encapsulamento, mas lembre-se de que eles não são privados de verdade (a convenção é apenas visual).
  • Evite lógica complexa dentro de getters, pois eles podem ser chamados muitas vezes, impactando a performance.
  • Use Object.defineProperty quando precisar definir propriedades em objetos que não são classes, ou quando precisar de controle fino sobre enumerabilidade e configurabilidade.

Referências

Exercícios

  1. Crie um objeto temperatura com propriedades internas _celsius e _fahrenheit. Defina getters e setters para celsius e fahrenheit que convertam automaticamente entre as unidades. Por exemplo, se você definir celsius para 100, fahrenheit deve retornar 212.

    ✓ Resposta:
    const temperatura = {
      _celsius: 0,
      get celsius() {
        return this._celsius;
      },
      set celsius(valor) {
        this._celsius = valor;
        this._fahrenheit = (valor * 9/5) + 32;
      },
      get fahrenheit() {
        return this._fahrenheit;
      },
      set fahrenheit(valor) {
        this._fahrenheit = valor;
        this._celsius = (valor - 32) * 5/9;
      }
    };
    
    temperatura.celsius = 100;
    console.log(temperatura.fahrenheit); // 212
    
  2. Usando Object.defineProperty, crie um objeto pessoa com uma propriedade idade que valide se o valor é um número entre 0 e 120 (inclusive). Se for inválido, lance um erro.

    ✓ Resposta:
    const pessoa = {};
    Object.defineProperty(pessoa, 'idade', {
      get() {
        return this._idade;
      },
      set(valor) {
        if (typeof valor !== 'number' || valor < 0 || valor > 120) {
          throw new Error('Idade inválida');
        }
        this._idade = valor;
      }
    });
    
    pessoa.idade = 30;
    console.log(pessoa.idade); // 30
    pessoa.idade = 150; // Erro
    
  3. Crie uma classe Pessoa com propriedades nome e sobrenome (privadas) e um getter nomeCompleto que retorna a concatenação. Também adicione um setter nomeCompleto que divide o valor em nome e sobrenome (pelo primeiro espaço).

    ✓ Resposta:
    class Pessoa {
      constructor(nome, sobrenome) {
        this._nome = nome;
        this._sobrenome = sobrenome;
      }
    
      get nomeCompleto() {
        return `${this._nome} ${this._sobrenome}`;
      }
    
      set nomeCompleto(valor) {
        const partes = valor.split(' ');
        this._nome = partes[0];
        this._sobrenome = partes.slice(1).join(' ');
      }
    }
    
    const pessoa = new Pessoa('João', 'Silva');
    console.log(pessoa.nomeCompleto); // João Silva
    pessoa.nomeCompleto = 'Maria Souza';
    console.log(pessoa._nome); // Maria
    console.log(pessoa._sobrenome); // Souza
    
  4. Usando Object.defineProperties, crie um objeto carrinho com uma propriedade itens (array) e um getter total que calcula a soma dos preços dos itens. Cada item deve ter preco.

    ✓ Resposta:
    const carrinho = {};
    Object.defineProperties(carrinho, {
      itens: { value: [], writable: true, enumerable: true },
      total: {
        get() {
          return this.itens.reduce((soma, item) => soma + item.preco, 0);
        }
      }
    });
    
    carrinho.itens.push({ nome: 'Camiseta', preco: 29.99 });
    carrinho.itens.push({ nome: 'Calça', preco: 79.99 });
    console.log(carrinho.total); // 109.98
    
  5. Explique a diferença entre usar getters/setters em um objeto literal e usar Object.defineProperty. Dê um exemplo de quando você escolheria um sobre o outro.

    ✓ Resposta: A principal diferença é que em objetos literais a sintaxe é mais concisa e direta, enquanto Object.defineProperty oferece controle adicional sobre as características da propriedade (enumerable, configurable, etc.) e permite definir propriedades em objetos existentes. Você usaria Object.defineProperty quando precisa definir propriedades dinamicamente, quando quer configurar atributos como enumerable ou configurable, ou quando está trabalhando com objetos que não são literais (ex.: criados por uma função construtora). Objetos literais são mais adequados para casos simples onde você está definindo o objeto de uma só vez.

Observações finais

Getters e setters são ferramentas essenciais no JavaScript moderno. Eles permitem que você crie APIs mais limpas e seguras, escondendo a complexidade interna. Lembre-se de que, apesar da convenção _, as propriedades ainda são acessíveis diretamente; para encapsulamento real, você pode usar # (campos privados) em classes, que veremos em aulas futuras. Pratique os exercícios e experimente criar seus próprios exemplos para dominar esse recurso.