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.