Getters e setters são recursos poderosos do JavaScript que permitem definir o comportamento de acesso e atribuição de propriedades de um objeto. Em vez de simplesmente armazenar um valor, você pode executar lógica personalizada quando uma propriedade é lida ou modificada. Isso é fundamental para o encapsulamento, validação de dados e criação de propriedades computadas, que são calculadas dinamicamente a partir de outros valores.

Nesta aula, vamos explorar como definir getters e setters em classes e objetos literais, como usar Object.defineProperty para um controle mais fino, e quais são os casos de uso mais comuns, como validação, normalização de dados e propriedades derivadas. Ao final, você terá uma compreensão sólida para aplicar esses conceitos em seus projetos.

Definindo acessores

Em JavaScript, getters e setters podem ser definidos em objetos literais e em classes. A sintaxe é simples: usamos as palavras-chave get e set antes do nome da propriedade. Um getter é uma função que é chamada quando a propriedade é lida, e um setter é chamado quando a propriedade é atribuída. Ambos são opcionais: você pode ter apenas um getter (propriedade somente leitura) ou apenas um setter (raro, mas possível).

Exemplo com objeto literal:

const pessoa = {
  _nome: 'João',
  get nome() {
    return this._nome;
  },
  set nome(valor) {
    if (valor.length < 3) {
      throw new Error('Nome muito curto');
    }
    this._nome = valor;
  }
};

console.log(pessoa.nome); // 'João'
pessoa.nome = 'Maria';
console.log(pessoa.nome); // 'Maria'
try {
  pessoa.nome = 'Al';
} catch (e) {
  console.log(e.message); // 'Nome muito curto'
}

Observe que usamos uma convenção de prefixo com underscore (_nome) para armazenar o valor real, pois o getter e o setter não podem ter o mesmo nome da propriedade que armazenam. Isso evita recursão infinita.

Em classes, a sintaxe é semelhante:

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

  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(100);
console.log(conta.saldo); // 100
conta.depositar(50);
console.log(conta.saldo); // 150
conta.saldo = 200;
console.log(conta.saldo); // 200
try {
  conta.saldo = -10;
} catch (e) {
  console.log(e.message); // 'Saldo não pode ser negativo'
}

Os getters e setters são especialmente úteis para validar dados antes de armazená-los, garantindo que o objeto permaneça em um estado consistente.

Object.defineProperty

Além da sintaxe direta, podemos usar o método estático Object.defineProperty para definir getters e setters em qualquer objeto, mesmo que já existente. Isso é útil quando você não pode alterar a definição original do objeto ou precisa definir propriedades com atributos específicos (como enumerable, configurable e writable). A sintaxe é:

Object.defineProperty(obj, 'nomeDaPropriedade', {
  get() { ... },
  set(valor) { ... },
  enumerable: true,
  configurable: true
});

Exemplo:

const carro = {};
let _velocidade = 0;

Object.defineProperty(carro, 'velocidade', {
  get() {
    return _velocidade;
  },
  set(novaVelocidade) {
    if (novaVelocidade < 0 || novaVelocidade > 200) {
      throw new Error('Velocidade inválida');
    }
    _velocidade = novaVelocidade;
  },
  enumerable: true,
  configurable: true
});

carro.velocidade = 100;
console.log(carro.velocidade); // 100
carro.velocidade = 250; // Erro: Velocidade inválida

Uma vantagem do Object.defineProperty é que ele permite criar propriedades computadas que dependem de outras propriedades do objeto. Por exemplo, podemos definir uma propriedade area que calcula a área de um retângulo a partir de largura e altura:

const retangulo = { largura: 10, altura: 5 };

Object.defineProperty(retangulo, 'area', {
  get() {
    return this.largura * this.altura;
  },
  enumerable: true
});

console.log(retangulo.area); // 50
retangulo.largura = 20;
console.log(retangulo.area); // 100

Note que a propriedade area é computada automaticamente sempre que é lida, refletindo as mudanças nas dimensões.

Casos de uso

Os getters e setters são amplamente utilizados em várias situações:

  • Validação de dados: Garantir que valores atribuídos atendam a certos critérios (ex.: idade positiva, nome não vazio).
  • Encapsulamento: Ocultar a implementação interna e expor uma interface controlada, como no caso de saldo bancário.
  • Propriedades computadas: Derivar valores automaticamente de outras propriedades (ex.: área, nome completo).
  • Normalização: Transformar valores ao atribuir, como converter para maiúsculas ou trim.
  • Lazy loading: Calcular um valor apenas quando necessário e armazenar em cache.

Um exemplo prático de propriedade computada é a criação de um nome completo a partir de nome e sobrenome:

class Usuario {
  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 user = new Usuario('João', 'Silva');
console.log(user.nomeCompleto); // 'João Silva'
user.nomeCompleto = 'Maria Santos Oliveira';
console.log(user.nome); // 'Maria'
console.log(user.sobrenome); // 'Santos Oliveira'

Outro caso comum é a normalização de dados, como garantir que um email seja sempre armazenado em minúsculas:

class Contato {
  constructor(email) {
    this._email = email.toLowerCase();
  }

  get email() {
    return this._email;
  }

  set email(valor) {
    this._email = valor.toLowerCase().trim();
  }
}

const contato = new Contato('Joao@Email.com');
console.log(contato.email); // 'joao@email.com'

Em frameworks como Vue.js, getters e setters são usados para reatividade, mas em JavaScript puro, eles são essenciais para criar APIs limpas e seguras.

Boas práticas

  • Use underscore (_) como convenção para propriedades privadas que serão acessadas por getters/setters.
  • Evite getters que tenham efeitos colaterais; eles devem apenas retornar um valor.
  • Setters devem validar dados e lançar erros quando necessário.
  • Prefira getters para propriedades computadas que dependem de outras propriedades.
  • Use Object.defineProperty quando precisar definir propriedades com atributos específicos ou em objetos que não podem ser alterados diretamente.

Referências

Exercícios

  1. Exercício 1: Crie um objeto literal chamado termometro com um getter celsius e um setter celsius. O setter deve aceitar apenas valores entre -100 e 100. Adicione também um getter fahrenheit que retorna a temperatura convertida (F = C * 9/5 + 32). Teste atribuindo valores válidos e inválidos.

    ✓ Resposta:
    const termometro = {
      _celsius: 0,
      get celsius() {
        return this._celsius;
      },
      set celsius(valor) {
        if (valor < -100 || valor > 100) {
          throw new Error('Temperatura fora do intervalo permitido');
        }
        this._celsius = valor;
      },
      get fahrenheit() {
        return this._celsius * 9 / 5 + 32;
      }
    };
    
    termometro.celsius = 25;
    console.log(termometro.celsius); // 25
    console.log(termometro.fahrenheit); // 77
    try {
      termometro.celsius = 200;
    } catch (e) {
      console.log(e.message); // 'Temperatura fora do intervalo permitido'
    }
  2. Exercício 2: Defina uma classe Pessoa com propriedades nome e idade. Adicione um getter maiorDeIdade que retorna true se a idade for maior ou igual a 18, e um setter idade que valida se o valor é um número positivo e menor que 150. Crie um objeto e teste.

    ✓ Resposta:
    class Pessoa {
      constructor(nome, idade) {
        this.nome = nome;
        this.idade = idade;
      }
    
      get idade() {
        return this._idade;
      }
    
      set idade(valor) {
        if (!Number.isInteger(valor) || valor < 0 || valor > 150) {
          throw new Error('Idade inválida');
        }
        this._idade = valor;
      }
    
      get maiorDeIdade() {
        return this._idade >= 18;
      }
    }
    
    const pessoa = new Pessoa('João', 20);
    console.log(pessoa.maiorDeIdade); // true
    try {
      pessoa.idade = -5;
    } catch (e) {
      console.log(e.message); // 'Idade inválida'
    }
  3. Exercício 3: Use Object.defineProperty para criar uma propriedade area em um objeto circulo que tenha raio. O getter deve calcular a área (π * raio²). Faça com que a propriedade raio seja enumerável e configurável.

    ✓ Resposta:
    const circulo = { raio: 5 };
    
    Object.defineProperty(circulo, 'area', {
      get() {
        return Math.PI * this.raio ** 2;
      },
      enumerable: true,
      configurable: true
    });
    
    console.log(circulo.area); // 78.53981633974483
    circulo.raio = 10;
    console.log(circulo.area); // 314.1592653589793
  4. Exercício 4: Crie uma classe ContaCorrente com um saldo privado (_saldo) e um limite de cheque especial. O getter saldoDisponivel deve retornar _saldo + limite. O setter saldo deve permitir que o saldo fique negativo até o limite negativo, mas não além. Teste com depósitos e saques.

    ✓ Resposta:
    class ContaCorrente {
      constructor(saldoInicial, limite) {
        this._saldo = saldoInicial;
        this.limite = limite;
      }
    
      get saldo() {
        return this._saldo;
      }
    
      set saldo(valor) {
        if (valor < -this.limite) {
          throw new Error('Saldo ultrapassa o limite do cheque especial');
        }
        this._saldo = valor;
      }
    
      get saldoDisponivel() {
        return this._saldo + this.limite;
      }
    
      depositar(valor) {
        this._saldo += valor;
      }
    
      sacar(valor) {
        if (valor > this.saldoDisponivel) {
          throw new Error('Saldo insuficiente');
        }
        this._saldo -= valor;
      }
    }
    
    const conta = new ContaCorrente(100, 200);
    console.log(conta.saldoDisponivel); // 300
    conta.sacar(250);
    console.log(conta.saldo); // -150
    console.log(conta.saldoDisponivel); // 50
    try {
      conta.sacar(100); // saldo ficaria -250, abaixo do limite -200
    } catch (e) {
      console.log(e.message); // 'Saldo insuficiente'
    }
  5. Exercício 5: Crie um objeto temperatura com getters e setters para Celsius e Fahrenheit, mas armazene internamente apenas em Celsius. O setter de Fahrenheit deve converter para Celsius antes de armazenar. Use Object.defineProperty para definir ambos.

    ✓ Resposta:
    const temperatura = {
      _celsius: 0
    };
    
    Object.defineProperty(temperatura, 'celsius', {
      get() {
        return this._celsius;
      },
      set(valor) {
        this._celsius = valor;
      },
      enumerable: true,
      configurable: true
    });
    
    Object.defineProperty(temperatura, 'fahrenheit', {
      get() {
        return this._celsius * 9 / 5 + 32;
      },
      set(valor) {
        this._celsius = (valor - 32) * 5 / 9;
      },
      enumerable: true,
      configurable: true
    });
    
    temperatura.celsius = 25;
    console.log(temperatura.fahrenheit); // 77
    temperatura.fahrenheit = 100;
    console.log(temperatura.celsius); // 37.77777777777778