Interfaces

Em geral, associamos o termo interface à interface gráfica do programa com o usuário. No âmbito da Orientação a Objetos, no entanto, o termo interface tem um significado particular.

Uma interface estabelece um contrato que deve ser cumprido por classes que implementam essa interface. Isso significa que qualquer classe que implemente uma interface deve fornecer uma implementação para todos os métodos e propriedades definidos na interface. A interface define o que uma classe deve oferecer, mas não como ela deve fazer.

💡 Entendendo o conceito

Considere o seguinte exemplo: algumas classes do programa devem ter as seguintes características: um atributo DataUltimoRelatorio, bem como um método chamado ImprimirRelatorio.

Em um primeiro momento, você pode pensar: posso usar herança para atender a essa demanda e incluir este atributo e este método na superclasse. Em alguns casos, essa solução pode até funcionar. Em outros casos, não.

Imagine que os recursos de relatório devem ser aplicados às classes Cliente e Produto. Tornar essas classes subclasse de uma única superclasse não parece a melhor solução, não é mesmo?

Para contornar essa limitação, podemos adotar o conceito de interface. Observe:

export interface IRelatorio{
    _dataUltimoRelatorio: Date;
    
    imprimirRelatorio(): void;
}

Nas interfaces, não usamos modificadores de visibilidade, pois todos os métodos e propriedades são implicitamente públicos. Além disso, as interfaces não fornecem implementação dos métodos; elas apenas definem a assinatura dos métodos que as classes devem implementar. Se o atributo for privado/protected, ele deve ser omitido da interface.

Agora, para forçarmos que as classes Cliente e Produto cumpram os requisitos da interface Relatorio, usamos a palavra reservada implements:

export class Cliente extends Pessoa implements IRelatorio{
    private _dataUltimoRelatorio: Date;
    
    public imprimirRelatorio(): void{
        console.log("Imprimir cliente");
    }
}
export class Produto implements IRelatorio{
    protected _dataUltimoRelatorio: Date;
    
    public imprimirRelatorio(): void{
        console.log("Imprimir produto");
    }
}

Note que, ao implementar a interface, cada classe fornece sua própria implementação para o método imprimirRelatorio, mas ambas garantem a presença do atributo _dataUltimoRelatorio e do método imprimirRelatorio conforme definido na interface.

Além disso, as interfaces podem ser usadas para definir métodos e propriedades opcionais usando o operador ?:

export interface IRelatorio{
    _dataUltimoRelatorio?: Date; // opcional
    metodoOpcional?(): void; // opcional
    imprimirRelatorio(): void; // obrigatório
}

Essa flexibilidade permite que algumas classes implementem apenas parte do contrato definido pela interface.

Por fim, interfaces podem ser combinadas com herança. Uma classe pode implementar múltiplas interfaces, permitindo que um objeto siga vários contratos ao mesmo tempo:

export class Produto implements IRelatorio, OutraInterface {
    // Implementações de métodos e propriedades
}

Dessa forma, as interfaces promovem a reutilização de código e a flexibilidade na definição de contratos entre diferentes partes de um sistema, sem forçar uma hierarquia rígida de classes.

💰 Herança em interfaces

Em TypeScript, o conceito de herança também se aplica a interfaces. Isso significa que uma interface pode estender outra, herdando seus métodos e propriedades. Veja o exemplo:

export interface InterfaceA{
  metodoA(): void;
}

Aqui temos uma interface InterfaceA, que define o método metodoA. Agora, podemos criar uma nova interface, InterfaceB, que herda de InterfaceA:

import { InterfaceA } from "./InterfaceA";

export interface InterfaceB extends InterfaceA{
  metodoB(): void;
}

Com essa relação de herança, qualquer classe que implemente InterfaceB deverá implementar tanto metodoB() quanto metodoA(), pois InterfaceB herda de InterfaceA.

import { InterfaceB } from "./InterfaceB";

export class Classe implements InterfaceB{
  public metodoA(): void{
    console.log("Implemento o método A");
  }
  public metodoB(): void{
    console.log("Implemento o método B");
  }
}

🧩 Herança múltipla em interfaces

Um recurso interessante em interfaces que não está presente nas classes é a herança múltipla. Em TypeScript, uma interface pode estender várias outras interfaces. Veja o exemplo abaixo:

export interface InterfaceA{
  metodoA(): void;
}

export interface InterfaceB{
  metodoB(): void;
}

export interface InterfaceC extends InterfaceA, InterfaceB {
  metodoC(): void;
}

Aqui, a InterfaceC herda de InterfaceA e InterfaceB. Portanto, qualquer classe que implemente InterfaceC terá que implementar os três métodos: metodoA(), metodoB() e metodoC().

export class Classe implements InterfaceC{
  public metodoA(): void{
    console.log("Implemento o método A");
  }
  public metodoB(): void{
    console.log("Implemento o método B");
  }
  public metodoC(): void{
    console.log("Implemento o método C");
  }
}

✴️ Uso adicional de interfaces

Na programação orientada a objetos (POO), objetos são instâncias de classes. No entanto, no JavaScript, objetos podem ser criados diretamente sem a necessidade de classes, como no exemplo abaixo:

let objeto = {};

Esses objetos podem possuir atributos e métodos:

let outroObjeto = {
  nome: "João da Silva",
  alterarNome: function (novo: string) {
    this.nome = novo;
  }
};

outroObjeto.alterarNome("Pedro");
console.log(outroObjeto.nome); // "Pedro"

Essa abordagem não segue o princípio clássico da orientação a objetos, onde objetos são instanciados a partir de classes. Isso ocorre porque JavaScript é baseado em um modelo de protótipos para herança, conforme descrito aqui.

Em TypeScript, podemos usar interfaces para garantir que objetos, mesmo quando criados diretamente, cumpram certos contratos ou requisitos. Uma interface define a estrutura que um objeto deve seguir, impondo restrições rigorosas: o objeto deve conter exatamente os métodos e atributos definidos pela interface, nem mais, nem menos.

Acompanhe os exemplos:

interface IExemplo {
    nome: string;
    metodo(): void;
}

/* Objeto que cumpre integralmente a interface */
let objetoCorreto: IExemplo = {
    nome: 'Jose',
    metodo: function () {
        console.log('Eu cumpro a interface');
    },
};

/* Objeto que não cumpre os requisitos da interface */
let objetoComErro: IExemplo = {
    nome: 'Jose',
};

/* Objeto que extrapola a interface (atributo 'idade' não está definido na interface) */
let objetoComErro2: IExemplo = {
    nome: 'Jose',
    idade: 20,
    metodo: function () {
        console.log('Eu cumpro a interface');
    },
};

🚀 Para testar na prática

Preparei um exemplo para vocês no PlayCode: clique aqui para acessar.