Há quem diga que JavaScript (e consequentemente TypeScript também) é melhor aproveitado no paradigma funcional do que no Orientado à Objetos, mas como um profissional que trabalhou mais de uma década com Java e C#, confesso que volta e meia me pego usando OO em aplicações Node.js e saber usar dos recursos OO disponibilizados a partir da especificação ES6 do JS é uma mão na roda. Não apenas isso, mas o uso de classes em TypeScript especificamente adiciona muitos poderes adicionais ao fornecido no JS tradicional, onde classes costumam ser vistas e tratadas apenas como syntax sugar.
No tutorial de hoje eu vou lhe ensinar o básico do uso de classes em TypeScript. Para que consiga aproveitar da melhor maneira possível este tutorial é interessante que você já saiba o que é Orientação à Objetos, o que ensino aqui. Além disso, não ensinarei o básico de TypeScript aqui, algo exaustivamente ensinado nesta série de tutoriais.
Se preferir, você pode assistir ao vídeo abaixo, o conteúdo é o mesmo.
Setup do Projeto
Crie uma pasta tutorial-classes na sua máquina e dentro dela inicialize um projeto Node.js.
1 2 3 |
npm init -y |
Depois instale dentro da pasta as dependências que vamos precisar.
1 2 3 |
npm i -D typescript ts-node |
Na sequência, inicialize o TypeScript neste projeto.
1 2 3 |
npx tsc --init |
Em seguida, configure o arquivo tsconfig.json para que procure os fontes na pasta src (que você deve criar) e que transpile os arquivos na pasta dist (que será criada automaticamente. Também tomei a liberdade de alterar a versão de ECMAScript para ES2020, que é a que mais tenho usado atualmente.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
{ "compilerOptions": { "target": "es2020", "module": "commonjs", "rootDir": "./src/", "outDir": "./dist/", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true } } |
Dentro da pasta src você já pode deixar um arquivo index.ts preparado para nossos testes e no package.json pode ajustar o script de start para inicializar este arquivo e ficar escutando por alterações, com nodemon.
1 2 3 4 5 |
"scripts": { "start": "npx nodemon ./src/index.ts" }, |
Pronto, agora temos tudo preparado para o tutorial.
Criando classes em TypeScript
Para quem não lembra ou ainda não aprendeu, uma classe é uma especificação, um tipo novo de objeto da sua aplicação. Por exemplo, uma classe Pessoa (inicie classes sempre com letra maiúscula e no singular) irá definir propriedades e funções comuns a pessoas da sua aplicação. Assim, quando for criar pessoas usando esta classe, elas sempre possuirão a mesma estrutura.
A primeira recomendação é que você use uma classe por arquivo TS, tranformando-o em um módulo TS que deverá ser importado onde se desejar usar essa classe. Esse arquivo deve ter o mesmo nome da classe, como abaixo, onde crio uma classe Customer, e deve ser a exportação default do módulo, por conveniência.
1 2 3 4 5 6 |
//Customer.ts export default class Customer { //attributes and methods here } |
Dentro do escopo da classe podemos criar seus atributos (variáveis internas) e métodos (funções internas), que estarão presentes em todos os objetos inicializados a partir desta classe. Abaixo, algumas sugestões de exemplo e repare que já os deixei tipados.
1 2 3 4 5 6 7 8 |
export default class Customer { name: string; age: number; email: string; birthDate: Date; } |
Você vai notar que o VS Code, ao detectar que seu projeto é TypeScript e que você declarou a classe acima, vai reclamar. Isso porque quando criamos uma classe com atributos (também chamados de propriedades), devemos atribuir valores default à eles ou então criar um método construtor. Vou falar de construtores mais à frente, mas primeiro, apenas atribua valores default aos atributos, como abaixo:
1 2 3 4 5 6 7 8 9 |
//customer.ts export default class Customer { name: string = ""; age: number = 0; email: string = ""; birthDate: Date = new Date(); } |
Abaixo um exemplo de como um index.ts pode ser utilizado para “brincar” com nossa nova classe.
1 2 3 4 5 6 7 8 9 |
//index.ts import Customer from "./customer"; const customer1 = new Customer(); customer1.name = "Luiz"; console.log(customer1) |
Para executar este arquivo, você pode usar o comando npm start (se ainda não o fez). Verá que ao imprimir o customer1, todos os dados default são apresentados, com exceção do name que alteramos para “Luiz”.
Agora se você não quiser ter valores default para os atributos, mas sim obrigar quem inicializar um novo cliente a preencher todos os campos (ou parte deles), o caminho é criar um construtor para esta classe. Um construtor é uma função especial que inicializa um objeto desta classe, usando argumentos e processamentos internos para definir as suas propriedades/atributos.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
//customer.ts export default class Customer { name: string; age: number; email: string; birthDate: Date; constructor(name: string, email: string, birthDate: Date) { this.name = name; this.email = email; this.birthDate = birthDate; this.age = new Date().getFullYear() - birthDate.getFullYear(); } } |
Esse construtor acima espera nome, e-mail e data e nascimento e os utiliza para definir as propriedades homônimas de todo cliente criado a partir dessa classe. Internamente, o construtor repassa esses valores para as propriedades do objeto, iniciadas com ‘this.’. Variáveis precedidas por ‘this.’ são atributos do objeto e seu uso é obrigatório em classes para não confundir com variáveis de outros escopos.
Note também que ele inicializa uma propriedade age de maneira automática e transparente, pegando o ano atual e comparando com o ano da data de nascimento. Embora este cálculo não seja 100% preciso ele serve como exemplo do tipo de processamento que pode ser realizado no construtor inclusive para validar e transformar dados passados como argumento.
O uso da palavra reservada constructor somente pode ser usada nessa função e ela é disparada automaticamente quando criamos um novo objeto Cliente usando a keyword new, como em outras linguagens orientadas a objeto (Java, C#, etc).
1 2 3 4 5 6 |
//index.ts import Customer from "./customer"; console.log(customer1) |
Se você voltar no trecho de código anterior, onde declarei o construtor, verá que cada um desses argumentos será colocado em uma propriedade interna do cliente e isso se torna evidente quando você imprime o objeto cliente1 no console no código acima.
Cada variável declarada como sendo um novo cliente tem o seu próprio conjunto de propriedades, mas a mesma estrutura básica, ficou claro?
Assim, se você declarar customer1, customer2, etc; cada um terá o seu nome, sua idade, etc. Independente um do outro, mas com o mesmo “esqueleto”.
Mas e os comportamentos?
Funções de classe em TypeScript
Toda classe é composta de atributos e métodos, também chamados de propriedades e funções. Essas funções, por uma questão de organização, devem ser sempre relativas à responsabilidade da classe em si, e geralmente manipulam ou utilizam as propriedades do objeto em questão. Assim, uma classe Customer terá funções que usam ou manipulam as propriedades do objeto customer em si.
Declarar uma função de classe (chamada de método em muitas linguagens OO) é feita dentro do escopo da mesma (abre e fecha-chaves mais externas). Não há necessidade da palavra function tradicionalmente usada, mas o restante segue a mesma lógica de functions tradicionais.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
//customer.ts export default class Customer { name: string; age: number; email: string; birthDate: Date; constructor(name: string, email: string, birthDate: Date) { this.name = name; this.email = email; this.birthDate = birthDate; this.age = new Date().getFullYear() - birthDate.getFullYear(); } isAdult() { return this.age >= 18; } getFirstName() { return this.name.split(" ")[0]; } } |
No exemplo acima descrevo que os objetos do tipo/classe Customer possuem duas funções: isAdult que retorna true/false com base na idade do objeto e outra chamada getFirstName que baseada no nome do objeto/cliente, retorna a primeira parte do mesmo (primeiro nome).
Para chamar estas funções você primeiro deve instanciar objetos do tipo Cliente e suas execuções devem produzir retornos conforme propriedades de cada objeto em particular.
1 2 3 4 5 6 7 8 9 |
//index.ts import Customer from "./customer"; console.log(customer1.isAdult()); console.log(customer2.isAdult()); |
No código acima, eu instancio dois clientes com dados diferentes e depois chamo a função isAdult pra ver quais deles são adultos ou não, com base na data de nascimento informada na sua criação.
O resultado você pode ver no seu console, mas basicamente o cliente de nome Luiz é adulto, enquanto que o cliente Pedro não é.
E por fim, note que você pode ter propriedades internas que, por sua vez, são do tipo de outra classe. Elas podem ser instanciadas com new dentro do próprio construtor, em uma variável antes ou até depois, conforme sua lógica necessitar.
Para este exemplo, considere a classe Address abaixo, declarada em outro arquivo na mesma pasta src:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
//address.ts export default class Address { street: string; district: string; city: string; state: string; constructor(street: string, district: string, city: string, state: string) { this.street = street; this.district = district; this.city = city; this.state = state; } toString() { return this.street + ", " + this.district + ", " + this.city + "/" + this.state; } } |
Note que criei uma função toString() que monta uma String baseada nas propriedades do endereço. Na verdade já existe uma função toString automaticamente em todas classes JavaScript, mas estamos sobrescrevendo seu comportamento padrão através de declaração de outra função de mesmo nome. Isso é chamado de sobrescrita na orientação à objetos, uma das formas conhecidas de sobrecarga de método/função.
Agora altere a nossa classe Customer para que eles possuam uma propriedade address também:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
//customer.ts import Address from "./address"; export default class Customer { name: string; age: number; email: string; birthDate: Date; address: Address; constructor(name: string, email: string, birthDate: Date, address: Address) { this.name = name; this.email = email; this.birthDate = birthDate; this.age = new Date().getFullYear() - birthDate.getFullYear(); this.address = address } |
Para testar o uso de objetos Address como propriedade do Customer, meu index.ts vai ficar assim:
1 2 3 4 5 6 7 8 9 10 11 |
//index.ts import Customer from "./customer"; import Address from "./address"; const address = new Address("Minha Rua 67", "Meu Bairro", "Minha Cidade", "RS"); const customer2 = new Customer("Pedro Duarte", "[email protected]", new Date(2014, 10, 3), address); console.log(customer1.address.toString()) |
O resultado é o endereço completo por extenso. Isso porque chamamos a função toString do objeto address. O uso de objetos dentro de outros objetos é o que chamamos de associação, sendo que podemos ter associações de agregação ou de composição. Bacana, não?
Herança em TypeScript
Para encerrar este artigo vamos falar agora de mais uma característica de Orientação à Objetos implementada no TypeScript: a Herança. Esse é um recurso que não existe em classes JS e foi uma grande adição do TS na minha opinião. Basicamente a Herança funciona assim: uma classe A, mais genérica, define propriedades e funções que podem ser herdadas por uma classe B (ou C, D, etc). Isso aumenta o reuso de código e consequentemente facilita a manutenção dos sistemas.
Experimente por exemplo criar um novo arquivo person.ts com uma classe Person dentro, representando uma pessoa genérica do sistema (vou usar como base aqui nosso customer original).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
//person.ts export default class Person { name: string; age: number; email: string; birthDate: Date; constructor(name: string, email: string, birthDate: Date) { this.name = name; this.email = email; this.birthDate = birthDate; this.age = new Date().getFullYear() - birthDate.getFullYear(); } isAdult() { return this.age >= 18; } getFirstName() { return this.name.split(" ")[0]; } } |
Agora vamos modificar nosso Customer para que ele herde as características de Person e apenas adicione o que for exclusivo de clientes, que neste exemplo (didático) será apenas o endereço. A herança de uma classe em relação à outra ocorre através da keyword extends, como abaixo.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
//customer.ts import Person from "./Person"; import Address from "./address"; export default class Customer extends Person { address: Address; constructor(name: string, email: string, birthDate: Date, address: Address) { super(name, email, birthDate); this.address = address } } |
No momento que eu digo que Customer extends Person, estou dizendo que todo objeto cliente, já nascerá também com todos os dados e funções de pessoa. O único detalhe que devo prestar atenção aqui é no constructor, que eu devo chamar na primeira linha dele a função super, que serve para chamar o constructor da superclasse (a classe da qual estou herdando). Essa é uma exigência e o TS não vai compilar se você não fizer.
Agora no nosso script de teste, não precisamos alterar absolutamente nada, mas repare que você pode chamar no objeto customer1 qualquer propriedade ou função que exista tanto em Customer quanto em Person, sem qualquer diferença. Na verdade, para o JS realmente não existe qualquer diferença, este é um recurso exclusivo do TS mesmo, que em tempo de compilação apenas copia os dados/funções da superclasse para as classes que herdam dela. A boa e velha syntax sugar.
Existem muitos outros conceitos relacionados à orientação à objetos presentes na linguagem TypeScript, mas por ora, os conceitos mais importantes ligados a classes em TS eu apresentei neste artigo.
Espero que tenham gostado!
Olá, tudo bem?
O que você achou deste conteúdo? Conte nos comentários.
Ótima explicação, direto ao ponto.
Fico feliz que tenha gostado Janderson!