Poznajemy dalej interfejsy. Nudne początki TSa, ale musimy przez to przebrnąć. Plus taki, że są to koncepty obecne w większości języków programowania z OOP i otwierają nam duże możliwości, których sam PHP + JS nie jest w stanie nas nauczyć.
Ok, poprzednio mieliśmy taki przykład dziedziczenia interfejsów:
interface Vehicle {
wheels: number;
maker?: string;
}
interface Car extends Vehicle {
power: "gas" | "electricity";
}
interface Bicycle extends Vehicle {
folding: boolean;
}
I to ma sens. Bo tutaj samochód i rower mają zupełnie inne pola. Zobaczmy na ten przykład wzorca factory:
interface Animal {
speak: () => void
}
class Dog implements Animal {
speak() {
console.log('Woof! I am Dog!')
}
}
class Cat implements Animal {
speak() {
console.log('Meoww! I am Cat!')
}
}
class AnimalFactory {
static createAnimal(type: 'dog' | 'cat'): Animal {
if (type === 'dog') {
return new Dog()
}
if (type === 'cat') {
return new Cat()
} else throw new Error()
}
}
const dog = AnimalFactory.createAnimal('dog')
dog.speak() // 'Woof! I am Dog!'
const cat = AnimalFactory.createAnimal('cat')
cat.speak() // 'Meoww! I am Cat!'
Tu mamy różne klasy, które mają tę samą metodę, tylko inaczej implementowaną, więc bardziej opłaca się użyć różnych klas.
Z drugiej strony – możemy utworzyć klasę abstrakcyjną i z niej dziedziczyć:
abstract class Animal {
abstract speak() : void
}
class Dog extends Animal {
speak() {
console.log('Woof! I am Dog!')
}
}
class Cat extends Animal {
speak() {
console.log('Meoww! I am Cat!')
}
}
class AnimalFactory {
static createAnimal(type: 'dog' | 'cat'): Animal {
if (type === 'dog') {
return new Dog()
}
if (type === 'cat') {
return new Cat()
} else throw new Error()
}
}
const dog = AnimalFactory.createAnimal('dog')
dog.speak() // 'Woof! I am Dog!'
const cat = AnimalFactory.createAnimal('cat')
cat.speak() // 'Meoww! I am Cat!'
W ten sposób te klasy są ze sobą powiązane czymś więcej niż tylko implementacją wspólnego interfejsu. Jasne, ogólnie się mówi, żeby dziedziczenia unikać (np. preferować kompozycję ponad dziedziczenie), ale w tym przypadku uważam, że powinno się używać klas abstrakcyjnych.
Inna sprawa, że to nieco więcej kodu generuje, ale w zasadzie niewiele więcej:
"use strict";
class Animal {
}
class Dog extends Animal {
speak() {
console.log('Woof! I am Dog!');
}
}
class Cat extends Animal {
speak() {
console.log('Meoww! I am Cat!');
}
}
class AnimalFactory {
static createAnimal(type) {
if (type === 'dog') {
return new Dog();
}
if (type === 'cat') {
return new Cat();
}
else
throw new Error();
}
}
const dog = AnimalFactory.createAnimal('dog');
dog.speak(); // 'Woof! I am Dog!'
const cat = AnimalFactory.createAnimal('cat');
cat.speak(); // 'Meoww! I am Cat!'
JS jest ślepy na interfejsy TSa, one są tylko po to, aby podczas produkcji kodu mieć compilation errory, dzięki którym lepszy kod powstaje, a błędy ujawniają się zawczasu, nie podczas działania.
Nawet nie znając filarów programowania OOP możemy się domyślić zatem, że klasa abstrakcyjna nie na darmo ma to słówko abstract przed metodami abstrakcyjnymi i nie zawsze musi być pusta, może mieć też konkretne definicje metod:
abstract class Animal {
abstract speak() : void
get42():number {
return 42;
}
}
Teraz nasz JS będzie wyglądał tak:
"use strict";
class Animal {
get42() {
return 42;
}
}
class Dog extends Animal {
speak() {
console.log('Woof! I am Dog!');
}
}
class Cat extends Animal {
speak() {
console.log('Meoww! I am Cat!');
}
}
I każda podklasa będzie miała dostęp do tej metody, natomiast w trakcie kompilacji TS sprawdzi, czy te klasy mają swoją implementację abstrakcyjnej metody speak. W zależności od ustawień kompilacja się przerwie, albo po prostu dostaniemy wiadomość, że mamy błąd.
Kolejny patent, jaki możemy zastosować, aby mieć dwie opcje do wyboru wygląda tak:
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
type Shape = Circle | Square;
function getArea(shape: Shape): number {
if (shape.kind === "circle") {
// We know it's a Circle interface here
return Math.PI * shape.radius ** 2;
}
// We know it's a Square interface here
return shape.sideLength ** 2;
}
const circle: Shape = { kind: "circle", radius: 5 };
const square: Shape = { kind: "square", sideLength: 4 };
console.log(getArea(circle)); // Output: 78.53981633974483
console.log(getArea(square)); // Output: 16
Czyli łączymy dwa interfejsy jako typ ze znakiem |, idzie się domyślić, że Shape daje nam dwie opcje do wyboru. Bawiąc się w to powinniśmy też ogarnąć funkcje do sprawdzania co jest jakiego typu:
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
type Shape = Circle | Square;
function isCircle(shape: Shape) : shape is Circle {
return (shape as Circle).radius !== undefined;
}
function getArea(shape: Shape): number {
if (isCircle(shape)) {
return Math.PI * shape.radius ** 2;
}
return shape.sideLength ** 2;
}
Na początku może to nieco przytłaczać, ale jak się wgłębimy w logikę, poeksperymentujemy, poczytamy, co nam vscode podpowiada, to zrozumiemy dlaczego tak to wygląda.
Generalnie nazywa się to type predicate. Dodam tylko, że możemy też bawić się inaczej, czyli zamiast | zrobić & i wtedy taki typ musi mieć pola z obu interfejsów.
To nawet zresztą interfejsy nie muszą być, mogą być typy, oto przykład:
type Colorful = {
color: string;
};
type Circle = {
radius: number;
};
type ColorfulCircle = Colorful & Circle; // intersection
function draw(circle: ColorfulCircle) {
console.log(`Radius was ${circle.radius}`); // ok
console.log(`Color was ${circle.color}`);
}
draw({ color: "blue", radius: 42 });
draw({ radius: 42 });
Ogarnijmy to sobie na spokojnie. Więcej interfejsów i OOP już niedługo.