TypeScript - Generics, klasy i zaawansowane typy

Wprowadzenie

W poprzednim wpisie poznałeś podstawy TypeScriptu, mam nadzieję, że przećwiczyłeś je w praktyce! Dziś coś dla fanów OOP, ale nie tylko! Poznamy również Generics i zaawansowane typy.

Klasy

Jeśli nie poznałeś jeszcze klas w JavaScripcie, zachęcem Cię do sprawdzenia tego materiału. Nie będziemy bowiem tutaj się zagłębiać w to czym są klasy, jak działają, tylko po to, żeby nie tracić Twojego czasu.

Public

public w TypeScripcie działają tak jak *normalne właściwości, nie musisz ich oznaczać.

class Person {
  public name: string;

  public constructor(personName: string) {
    this.name = personName;
  }

  public getPersonAge(personAge: number) {
    console.log(`${this.name} is ${personAge} years old`);
  }
}

*Public znajduje swoje zastosowanie w parametrach konstruktora, o których za chwilkę.

Private

Każdą właściwość możemy zmienić na prywatną, poprzedzając ją słowem private. Taka właściwość nie będzie dostępna poza klasą, w której się znajduje.

class Person {
  private name: string;

  constructor(personName: string) {
    this.name = personName;
  }

  public getPersonAge(personAge: number) {
    console.log(`${this.name} is ${personAge} years old`);
  }
}

const employer = new Person("Jakub");
employer.name; // error

Ciekawostka: Prywatne właściwości nie są chronione podczas runtime'u

Protected

Podobne do private, różnią się tym, że protected możemy użyć podczas dziedziczenia.

class Person {
  protected name: string;

  constructor(personName: string) {
    this.name = personName;
  }

  public getPersonAge(personAge: number) {
    console.log(`${this.name} is ${personAge} years old`);
  }
}

class Employee extends Person {
  private company: string;

  constructor(name: string, company: string) {
    super(name);
    this.company = company;
  }

  public printEmployeeInfo() {
    return `Hi, I'm ${name} and I work for ${this.company}`;
  }
}

const jakub = new Employee("Jakub", "Firma_Krzak");
jakub.printEmployeeInfo(); // Hi, I'm Jakub and I work for Firma_Krzak
jakub.name; // error

Zauważ, że nie możemy użyć name poza klasą, a jedynie w klasie dziedziczącej, lub w niej samej.

Protected Constructor

Wykorzystajmy nabytą wiedzę i użyjmy protected przy konstruktorze. Co daje nam taki zabieg? Dzięki temu będziemy mogli tylko dziedziczyć daną klasę, a nie ją instancjonować.

class Person {
  protected constructor(protected personName: string) {}
}

class Employee extends Person {
  private company: string;
  constructor(name: string, company: string) {
    super(name);
    this.company = company;
  }

  public printEmployeeInfo() {
    return `Hi, I'm ${name} and I work for ${this.company}`;
  }
}

const jakub = new Employee("Jakub", "Firma_Krzak"); // Wszystko ok
const bartek = new Person("Bartek"); // error

Readonly

Znane Ci już readonly możemy użyć również w klasach:

class Person {
  protected readonly name: string;
  constructor(personName: string) {
    this.name = personName;
  }
  changeName() {
    this.name = "Marek"; // error
  }
}

Parametry konstruktora

Mamy możliwość, by użyć powyższych parametrów w naszym konstruktorze! Tutaj public może nam się przydać. Co daje nam, w tym przypadku, TypeScript?

  • Deklaruje instancje właściwości o tej samej nazwie
  • Przypisuje dany parametr do tej instancji

Poniższe klasy działają na takiej samej zasadzie.

// Wersja pierwsza
class Person {
  name: string;
  protected age: number;
  private readonly isMarried: boolean;
  constructor(name: string, age: number, isMarried: boolean) {
    this.name = name;
    this.age = age;
    this.isMarried = isMarried;
  }
}

// Parametry konsturktora

class Person {
  constructor(
    public name: string,
    protected age: number,
    private readonly isMarried: boolean
  ) {}
}

Interfejsy w klasach

Mam nadzieję, że kojarzysz podstawowe założenia idące za Interfejsami. Jeśli nie, zachęcam Cię do sprawdzenia poprzedniego wpisu.

Interfejsy wykorzystujemy w klasach, używając słowa implements. Możemy ich podać kilka, mogą również być one rozszerzane.

interface PersonAge {
  getPersonAge(personAge: number): void;
}

class Person implements PersonAge {
  protected name: string;
  constructor(personName: string) {
    this.name = personName;
  }
  public getPersonAge(personAge: number) {
    console.log(`${this.name} is ${personAge} years old`);
  }
}

Klasy abstrakcyjne

Klas abstrakcyjnych nie możemy instancjonować. Tylko klasy dziedziczne mogą to robić, no chyba, że są również abstrakcyjne. Abstrakcyjne klasy mogą również posiadać abstrakcyjne metody, nie mogą być one implementowane, posiadają tylko tzw. sygnaturę typów. Do oznaczania abstrakcyjnych klas i ich metod używamy słowa abstract.

abstract class Person {
  constructor(readonly name: string) {}
  abstract getPersonAge(personAge: number): void;
}

const jakub = new Person("Jakub"); // error

Przypominają one Intefejsy, ale nimi nie są. Abstrakcyjne klasy mogą zawierać również metody z jakąś implementacją (metody bez abstract), interfejsy tego nie robią.

Ciekawostka: Abstrakcyjne klasy występują tylko w procesie kompilacji, podczas runtime'u zachowują się jak normalne klasy.

Typy generyczne(Generics)

Pozwalają nam one nadawać dynamicznie typy. Wyznaczają, w pewnym sensie, kolejny poziom abstrakcji. Mają zastosowanie w funkcjach, interfejsach i klasach.

Funkcje

Spójrzmy na przykład (to jeszcze nie jest typ generyczny):

function identity(arg: number): number {
  return arg;
}

Mamy tutaj funkcję, która przyjmuje parametr typu number i po prostu go zwraca. Jest parę problemów z tą funkcją. Po pierwsze, ciężko by nam było ją w jakimś stopniu rozbudować, a poza tym mamy tutaj na sztywno wklepany typ number, dla argumentu i zwracanej wartości. Pierwsza myśl, która mogłaby Ci przyjść do głowy, to użycie any. Nie jest to jednak dobry sposób, tracimy wtedy całą kontrolę nad typami.

Przekształćmy teraz naszą funkcję na funkcję generyczną.

function someFunc<T>(arg: T): T {
  return arg;
}

someFunc<string>("Awesome!"); // Awesome

Po nazwie funkcji podajemy <T>, to samo jeśli chodzi o typ parametru i zwracany typ, tylko że bez nawiasów. Jeśli mamy jeden argument, często spotykaną praktyką jest użycie właśnie literki T, drugi argument za to może być jako U. Przekształćmy teraz funkcje tak, aby zwracała tuple medali olimpijskich.

function getOlympicMedals<T, U>(arg1: T, arg2: U): [T, U] {
  return [arg1, arg2];
}

getOlympicMedals<string, string>("1st 🥇", "2nd 🥈"); // ["1st 🥇","2nd 🥈"]

Klasy

Typów generycznych, jak już wcześniej wspominałem, możemy użyć również z klasami:

class Animal<T> {
  constructor(public name: T) {}

  getAnimalName(name: T) {
    console.log(name);
  }
}
const giraffe = new Animal<string>("Skittles");
giraffe.getAnimalName("Skittles");

Interfejsy

Przyszedł czas na generyczne interfejsy, tutaj podobna sytuacja.

interface Person<V, W> {
  userName: V;
  hobbies: W[];
}

function getUserInfo<T, U>(userName: T, hobbies: U[]): Person<T, U> {
  const user: Person<T, U> = {
    userName,
    hobbies,
  };
  return user;
}
getUserInfo<string, string>("Przemek", [
  "programming",
  "boxing",
  "windsurfing",
]);

Generic Constraints

Jest to pewne nadawanie restrykcji dla generycznych typów. W tym celu używamy słowa extends. Mamy tutaj interfejs User, którego wartość age jest typu number. W funkcji rozszerzamy typ U typem User[].

interface User {
  age: number;
}

function combineUserInfo<T extends object, U extends User[]>(a: T, b: U) {
  return Object.assign(a, b);
}
combineUserInfo({ name: "Bob" }, [{ age: 23 }]);

Użycie typu parametru

Możesz zadeklarować typ, który jest wymuszony przez inny typ parametru. Dla przykładu weźmy wartość obiektu, podając jej nazwę. Skorzystamy tutaj z keyof.

function getUserLocation<T extends object>(obj: T, key: keyof T) {
  return obj[key];
}

let user = { userName: "Maciej", age: 22 };

getUserLocation(user, "userName"); // wszystko ok
getUserLocation(user, "location"); // error: Argument o typie "location" nie jest przypisywalny do  'userName' lub "age".

Zaawansowane typy

Unknown

W poprzednim wpisie przekazałem Ci, że any, może być przydatne przy zaciąganiu jakiś zewnętrznych danych (API), jest to najpopularniejsza metoda, ale nie najlepsza. TypeScript od pewnego czasu daje nam lepszy sposób.

Przedstawiam Ci typ unknown, to właśnie on może być skuteczny przy danych z API. Jest on bardzo podobny do any, bo tak jak any może przyjąć każdy typ. Samo unknown nie jest jednak przypisywalne do innej wartości niż any lub unknown, bez aktywnej asercji typów. Dlaczego unknown jest lepszym wyborem od any? Daje nam on podobną swobodę, ale pilnuje nas, przed nieświadomym przypisaniem do poprawnie otypowej zmiennej.

const userName: any = "Kamil";
const userAge: unknown = 23;

const newName: string = userName; // wszystko okej
const newAge: number = userAge; // typ unknown nie jest przypisywalny do typu number

Aliasy typów

Pozwala na zdefiniowanie aliasu danego typu. Często w przykładach wykorzystywaliśmy typ string dla userName. Jeśli zdefiniujemy sobie taki alias, będziemy mogli go używać w wielu miejscach.

type UserName = string;

function getUserName(userName: UserName): UserName {
  return userName;
}

const newUserName: UserName = "Tomek";

Intersection Types

Połączenie dwóch typów w jeden:

type Animal = {
  name: string;
  breed: string;
};

type Human = {
  name: string;
  origin: string;
};

type Wolverin = Animal & Human;

Wykorzystywany często z interfejsami:

interface Animal {
  name: string;
  breed: string;
}

interface Human {
  name: string;
  origin: string;
}

function transformToWolf(character: Animal & Human){...};

Wykorzystanie z operatorem OR:

type ArrayOrObject = [] | {};
type ArrayOrNull = [] | null;

type UniversalType = ArrayOrObject & ArrayOrNull; // []

Union Types i Type guard

Pozwala opisać typ jeden z dwóch (lub wielu). Możemy go fajnie użyć z tzw. Type guardem. Co to jest type guard? Type guard pozwala nam sprawdzić np. Czy dana zależność znajduje się w obiekcie. in to zależność JavaScriptowa, nie TS'owa. Type guardem może być również typeof czy też instanceof, metod na type guardy jest wiele.

interface Animal {
  name: string;
  breed: string;
}

interface Human {
  name: string;
  origin: string;
}

type Wolverin = Animal | Human;

const printBreed = (character: Wolverin): void | null => {
  if ("breed" in character) {
    console.log(`Breed: ${character.breed}`);
  }
  return null;
};

const character = {
  name: "Wolverine",
  breed: "🐈",
};

printBreed(character);

String/Numeric literals types

String literals to taka kombinacja union types,type guards i aliasów. Powiedzmy, że zamiast po prostu typu string, potrzebujemy sprawdzać konkretne wartości. To samo tyczy się Numeric literals type.

type WolverineEnemies = "Daken" | "Sabretooth" | "Lady Deathstrike";

class WolverineFight {
  fightWithEnemie(health: number, enemy: WolverineEnemies) {
    if (enemy === "Daken") {
      // ..
    } else if (enemy === "Sabretooth") {
      //...
    } else if (enemy === "Lady Deathstrike") {
      //...
    } else {
      // błąd!
    }
  }
}

let firstFight = new WolverineFight();
firstFight.fightWithEnemie(50, "Sabretooth");

Discriminated Unions

Możesz łączyć samodzielne typy, union types, type guardsy, aliasy typów i stworzyć zaawansowany patern zwany Discriminated Union.

Przykład wykorzystanie z interfejsami dość jasno przedstawia dokumentacja:

interface Square {
  kind: "square";
  size: number;
}
interface Rectangle {
  kind: "rectangle";
  width: number;
  height: number;
}
interface Circle {
  kind: "circle";
  radius: number;
}

type Shape = Square | Rectangle | Circle;

function area(s: Shape) {
  switch (s.kind) {
    case "square":
      return s.size * s.size;
    case "rectangle":
      return s.height * s.width;
    case "circle":
      return Math.PI * s.radius ** 2;
  }
}

Więcej zaawansowanych typów możesz znaleźć w dokumentacji. Nie wspomniałem tutaj, chociażby o mapped types, polimorficznym this i kilku innych.

Utility Types

Są to globalne typy pomocnicze. Przydają się, jeśli mamy kilka typów, które są np. readonly lub optional. Mają podobną składnię do typów generycznych.

Partial

Transformuje wszystkie typy do typów oznaczonych jako opcjonalne.

interface Todo {
  title: string;
  description: string;
}

function updateTodo(todo: Todo, fieldsToUpdate: Partial<Todo>) {...}

Readonly

Przekształca wszystkie typy do typów readonly

interface Todo {
  title: string;
}

const todo: Readonly<Todo> = {
  title: "Napisać nowy wpis na Frontlive.pl",
};

todo.title = "TypeScript jest super!"; // błąd!

Mamy dostęp do aż 16 utility types, znajdziesz je wszystkie tutaj.

Object & object

W TypeScriptcie mamy dwa sposoby na typowanie obiektów. Object jest typem dla instancji klasy Object, natomiast object jest typem dla wszystkich, nieprymitywnych wartości. Różnica w nazewnictwie dość subtelna, ale są to dwie zupełnie inne rzeczy. Zobaczmy, jak to działa w praktyce.

const getUserName = (userName: Object) => {...}
getUserName('Kamil'); // Wszystko okej

const getUserFullName = (fullName: object) => {...}
getUserFullName('Kamil Kowalski'); // error, prymitywna wartość

Załóżmy, że chcielibyśmy mieć obiekt z metodą toString(), jeśli nadajmy typ obiektowi - Object to dostaniemy błąd o niekompatybilności typów, jeśli typ naszego obiektu będzie objekt, wszystko powinno być okej.

const user: Object = {
  toString() {...} // błąd!
};

const anotherUser: object = {
  toString(){...}
}

Type vs Interface

Poznaliśmy już aliasy typów i interfesjy, pewnie wielu z was zastanawia się, jakie różnice są pomiędzy nimi.

Tak, jak już Ci pokazywałem w poprzednim wpisie, ale nie podając jeszcze tej nazwy, object type literals możemy wpisywać inlinowo, czego nie da się zrobić z interfesjami.

// Object type literals
const getUserName = (user: {userName: string}) => {...}

// Interfejsy
interface User {
  age: number;
}

const getUserAge = (user: User) => {...}

Typów aliasów nie możemy duplikować, jeśli zduplikujemy interfejs, to złączy on się w jeden, praktyka znana jako Declaration merging.

// błąd!
type User = {
  userName: string;
};

// błąd!
type User = {
  age: number;
};

interface Dog {
  name: string;
}

interface Dog {
  age: number;
}

InterfacevsType

Różnic jest jeszcze kilka, czy to skrócony zapis, wygoda, mapowane typy czy też polimorficzny this. Jeśli chcesz dowiedzieć się więcej, polecam Ci to issue na githubie.

Podsumowanie

To tyle na dziś. Chciałem dorzucić jeszcze tutaj kilka tematów, ale i tak wpis wyszedł trochę długi, więcej za tydzień!.

Przedstawiłem Ci tutaj tylko wąskie spojrzenie na cały temat, jeśli będziesz zainteresowany, zawsze możesz spojrzeć do dokumentacji i odkrywać nowe rzeczy, nie da się bowiem w jednym artykule rozwinąć każdego tematu w 100%. Jeśli zainteresował Cię TypeScript i nie chcesz czytać dokumentacji, ale chciałbyś zagłębić się jeszcze bardziej w ten świat, to zachęcam do przeczytania jakieś dobrej książki o TypeScripcie, na przykład tej: TypeScript na poważnie - Michał Miszczyszyn.

Jeśli dotrwałeś do końca, to przyjąłeś konkretną dawkę wiedzy i pewnie Twój mózg, tak samo jak mój, gdy tego wszystkiego się uczyłem (wciąż uczę), eksplodował 🤯.

Pamiętaj, żeby przećwiczyć zdobytą dziś wiedzę, możesz popróbować i rozbudować powyższe przykłady lub pobawić się w TypeScriptowym playgroudzie!

Do usłyszenia za tydzień!

Źródła

GitHubGitHubGitHub