Pytania rekrutacyjne TypeScript - okładka

Web developer – pytania rekrutacyjne cz. 7 – TypeScript

Jest to kolejny wpis z serii wpisów z pytaniami rekrutacyjnymi na stanowisko web developera. Listę wszystkich poprzednich wpisów z tej serii znajdziesz poniżej. Zachęcam Cię do zapoznania się jeśli jeszcze nie miałeś/aś okazji:

Tym razem pochylę się nad TypeScriptem i przedstawię kilka pytań sprawdzających wiedzę o najpopularniejszym nadzbiorze JavaScriptu. Tym razem jednak pytania nie pochodzą ze strony fefaq.pl, a są pytaniami opracowanymi przeze mnie na podstawie moich doświadczeń i przemyśleń.

1. Czym się różni typ any od typu unknown?

Typ any, jak sama nazwa sugeruje, mówi że zmienna tego typu może być czymkolwiek. Oznacza to, że gdy w danym miejscu kompilator spodziewa się przekazania konkretnego typu, to można tam również przekazać wartość typu any i nie spowoduje to błędu transpilacji. Na przykład:


const a: any = true;

function x( a: string ): void {
   // code
}

function y( a: number ): void {
   // code
}

x( a );
y( a );

W obu przypadkach kompilator TypeScript nie zasygnalizuje błędu. W tym miejscu należy powiedzieć, że korzystanie z typu any powinno się zredukować do minimum. Jedną z głównych zalet TypeScriptu jest właśnie typowanie, więc korzystanie z any gdy nie jest to absolutnie konieczne jest według mnie jednoznacznie złą praktyką. Zalecam ustawienie w pliku tsconfig.json wartości noImplicitAny na true oraz zastosowanie reguły @typescript-eslint/no-explicit-any w konfiguracji ESLint.

Typ unknown na pierwszy rzut oka jest bardzo podobnym typem. Tutaj również warto zwrócić uwagę na nazwę. Tak jak nazwa any sugeruje, że wartość może być czymkolwiek, tak typ unknown sugeruje że typ wartości pozostaje nieznany. Typ unknown nie pozwala na pewne rzeczy na które pozwala typ any, jest nieco bardziej restrykcyjny. Po pierwsze, do zmiennej określonego typu, np. number możemy przypisać wartość typu any lecz nie możemy przypisać wartości typu unknown. Po drugie, na wartościach typu unknown nie możemy wywoływać metod zarezerwowanych dla innych typów np. toString czy isNan. Oczywiście dotyczy to też bardziej złożonych czy typów zdefinowanych przez nas samych. Tu również przydatny okaże się przykład:


const a: any = true;
const b: unknown = true;

let c: number;

c = a; // Ok
c = b; // Błąd - Object is of type 'unknown'.(2571)

a.toString(); // Ok
b.toString(); // Błąd - Object is of type 'unknown'.(2571)

Oczywiście istnieje możliwość na wywołanie metody toString na wartości typu unknown poprzez skorzystanie z operatora as i casting na inny typ zawierający tę metodę, ale to również nie należy do dobrych praktyk. Z obu typów, zdecydowanie lepiej jest wybrać typ unknown, ale jeszcze lepiej jest redukować użycie obu typów do niezbędnego minimum, co oczywiście nie zawsze jest możliwe.

2. Czy da się w jakiś sposób „wyłączyć/zawiesić kompilator TypeScript”, na przykład dla konkretnej linii kodu? Jeśli tak, to w jaki sposób?

Odpowiedź brzmi – tak, da się. Pytanie celowo nie zawiera słowa „zignorować”, mimo że ono tam idealnie pasuje, ponieważ to ono jest kluczem do odpowiedzi na powyższe pytanie. TypeScript oferuje kilka klauzul pozwalających na dodawanie wyjątków dla kompilatora:

  • @ts-ignore – w przypadku gdy kompilator TypeScript zasygnalizuje wystąpienie błędu, umieszczenie tej klauzuli linię wyżej, pozwala na zignorowanie tego błędu przez kompilator.
  • @ts-expect-error – klauzula umieszczana w miejscu, gdzie programista spodziewa się wystąpienia błędu. Różnica między tą klauzulą a @ts-ignore jest taka, że jeżeli błąd nie wystąpi, to samo wystąpienie klauzuli w kodzie będzie interpretowane jako błąd.
  • @ts-nocheck – cały kod poniżej tej klauzuli nie będzie sprawdzany przez kompilator TypeScript. Jest to blokowy odpowiednik dla @ts-ignore.
  • @ts-check – odwrotność powyższej klauzuli pozwalający na przywrócenie pełnego sprawzdania kodu.

Stosowanie tych klauzul powinno być ostatecznością. Zdecydowanie lepiej jest znaleźć przyczynę problemu i naprawić problem niż korzystać z tych klazul. Uzasaddnionym przypadkiem użycia może być wykorzystanie błędnie otypowanych bibliotek. Oczywiście zawsze można spróbować wystawić pull requesta do takiej biblioteki ale nie każda biblioteka jest utrzymywana i nie zawsze jest na to czas.

Warto także rozważyć włączenie reguły @typescript-eslint/ban-ts-comment w swojej konfiguracji ESLint z wartością allow-with-description oraz ustawionym parametrem minimumDescriptionLength. Sprawi to, że ESLint zaakceptuje powyższe klauzule, ale tylko jeżeli będą zawierały wyjaśnienie o pewnej wymaganej długości znaków. Dzięki temu, każde użycie klazul będzie nieco bardziej przemyślane i wyjaśnione szerzej niż „bug” czy „won’tfix”.

Typy generyczne w TypeScript - kotek

3. Czym są tzw. „generyki”? Podaj przykład zastosowania.

„Generyki” lub też typy generyczne pozwalają na dynamiczne przekazanie typu do konstrukcji w kodzie, na przykład funkcji czy interfejsów. Myślę że najlepiej będzie to pokazać na przykładzie. Załóżmy że mamy API, które zwraca listę kotków oraz piesków. Dodatkowo, lista ta wspiera paginację. Czyli zwrócony obiekt z API będzie zawierał: numer strony, kursor do poprzedniej strony, kursor do następnej strony, oraz listę zwróconych kotków lub piesków. Poniższy przykład pokazuje jak można stworzyć reużywalny interfejs, który będzie w stanie osbłużyć zarówno kotki jak i pieski:


interface ICat {
   // Properties
}

interface IDog {
   // Properties
}

interface IPaginationResult<T> {
   page: number;
   prev_page: null | string;
   next_page: null | string;
   items: T[];
}

function getDogs(): IPaginationResult<IDog> {
   // code
}

function getCats(): IPaginationResult<ICat> {
   // code
}

Dzięki użyciu generyków, za pomocą jednego interfejsu zdefiniowaliśmy kształt zwracanego obiektu dla dwóch metod. Co więcej nic nie stoi na przeszkodzie, aby użyć tego interfejsu do zwrócenia np. ptaszków czy rybek w przyszłości.

4. Co TypeScript oferuje w zakresie definiowania właściwości klasy w konstruktorze? Czy można w jakiś sposób skrócić zapis?

TypeScript oferuje możliwość skróconego definiowania właściwości przekazanych przez konstruktor. Dzięki temu taki kod:


class Foo {
   private readonly _a: string;
   private readonly _b: string;
   private readonly _c: string;

  constructor( a: string, b: string, c: string ) {
    this._a = a;
    this._b = b;
    this._c = c;
  }
}

można zastąpić następującą formą:


class Foo {
  constructor(
     private readonly _a: string,
     private readonly _b: string,
     private readonly _c: string
   ) {}
}

Uważam taki zapis za znacznie bardziej czytelny i miły dla oka.

5. Czym są typy enum oarz tuple?

Typ enum, czyli inaczej typ wyliczeniowy pozwala na zdefiniowanie zestawu nazwanych stałych, zarówno w formie numerycznej jak i formie łańcucha znaków.


enum Colors {
  Red,
  Green,
  Blue
}

console.log( Colors.Red ); // 0

Tak zdefiniowany typ wyliczeniowy przypisze rosnąco liczby całkowite zaczynając od 0 dla każdego zdefiniowanego elementu. Oczywiście można również zdefiniować te liczby ręcznie:


enum Colors {
  Red = 1,
  Green = 2,
  Blue = 3
}

console.log( Colors.Red ); // 1

Tak jak wspomniałem, możliwe również jest zdefinowanie wartości jako łańcucha znaków:


enum Colors {
  Red = '#FF0000',
  Green = '#00FF00',
  Blue = '#0000FF'
}

console.log( Colors.Red ); // '#FF0000'

Z kolei typ tuple, lub też inaczej krotka, jest strukturą danych pozwalającą na zdefiniowanie listy z wartościami o konkretnych typach w konkretnych polach. Można doszukać się tu pewnych analogii z strukturami tabel i rekordami w relacyjnych bazach danych:


let user: [ string, number, string ]; // Imię, wiek, zawód

user = [ 'Steve', 21, 'Programmer' ]; 

Typy użytkowe w TypeScript - pędzle

6. Wymień kilka typów użytkowych (utility types).

Pierwszy z typów to typ Partial<T>. Znajduje on zastosowanie w momencie, gdy mamy interfejs gdzie wszystkie z pól mogą być opcjonalne. Zamiast definiować każde z pól interfejus jako opcjonalne, można wykorzystać do tego celu typ Partial<T>:


interface IExample {
   foo: string;
   bar: string;
   baz: number;
}

const a: Partial<IExample> = {
   foo: 'foo'
};

Powyższy kod jest w pełni poprawnym kodem dla kompilatora TypeScript, mimo że obiekt a nie zawiera wszystkich włściwości interfejsu. Może to zostać wykrozystane na przykład gdy kod oczekuje, przekazania jakiegoś obiektu, np. fragmentu konfiguracji, która zawiera predefiniowane wartości domyślne.

Kolejny użyteczny typ to Require<T>, który jest przeciwieństwem typu Partial<T>. W tym przypadku wszystkie właściwości przekazanego interfejsu są wymagane, niezależnie od tego czy zostały oznaczone jako opcjonalne, czy też nie:


interface IExample {
   foo?: string;
   bar: string;
   baz: number;
}

const a: Required<IExample> = {
   bar: 'bar',
   baz: 3
}; // Property 'foo' is missing in type '{ bar: string; baz: number; }' but required in type 'Required<IExample>'.(2741)

Inny przydatnym typem jest typ Readonly<T>. Sprawia on, że wszystkie właściwości stają się właściwościami tylko do odczytu:


interface IReadonlyExample {
   readonly foo: string;
   readonly bar: string;
}

interface IExample {
   foo: string;
   bar: string;
}

// Readonly<IExample> === IReadonlyExample

Następny typ warty uwagi to typ Record<K,V> pozwalający na zdefiniowanie mapy klucz-wartość. Jest on znacznie bardziej użyteczny niż typ object, gdyż pozwala na zdefiniowanie ścisłego zestawu lub typu kluczy oraz wartości. Dzięki temu typowanie za pomocą Record<K,V> jest o wiele bardziej precyzyjne, niż typowanie za pomocą typu object.

Samych typów użytkowych jest sporo więcej, a całą listę znajdziesz w dokumentacji TypeScript. Te które przedstawiłem, są jednymi z najczęściej przeze mnie wykorzystywanych.

7. Jakie typowanie wykrzystuje TypeScript i jakie niesie to konsekwencje?

TypeScript wykorzystuje typowanie strukturalne, co niesie za sobą kilka konsekwencji – zarówno pozytywnych jak i negatywnych.

Pierwszą konsekwencją jest to, że pozwala to na pewną dowolność w przypisywaniu wartości do zmiennych o określonym typie. Kompilator TypeScript sprawdza jedynie, czy struktura przypisywanego obiektu jest zgodna ze strukturą oczekiwaną:


interface Pet {
   name: string;
}

interface Person {
   name: string;
}

let steve: Person;

const nemo: Pet = {
   name: 'nemo'
}

steve = nemo;

Jest to broń obosieczna, z uwagi na to że programista zyskuje większą elastyczność, lecz kosztem sytuacji gdzie możemy przypisać obiekt tylko na podstawie zgodności interfejsów. W przykładzie powyżej, osoba steve została zwierzakiem o imieniu nemo.

Kolejną konsekwencją typowania strukturalnego jest to, że w przypadku typowania przy użyciu klas sprawdzane są wszystkie pola klasy, również te prywatne. Jest to w pewien sposób zrozumiałe i oczekiwane zachowanie z punktu widzdenia typowania strukturalnego, ale w większych projektach, gdzie wykorzystane są moduły w róznych wersjach z subtelnymi zmianami w klasach może to powodować sporo problemów. Co więcej, prywatne właściwości nie są dostępne spoza klasy, a mimo to wciąż mają swój udział w typowaniu co również może powodować spore problemy. Stąd też zdecydowanie odradzam typowanie przy użyciu klas na rzecz interfejsów. Zachęcam też do przeanalizowania poniższego przykładu:


class Person {
   private _age: number;

   constructor( private readonly _name: string ) {
      this._age = 21;
   }
}

class Pet {
   constructor( private readonly _name: string ) {}
}

let steve: Person;

const nemo: Pet = new Pet( 'nemo' );

steve = nemo; // Błąd - Property '_age' is missing in type 'Pet' but required in type 'Person'

8. Czym są type guards i w jakim celu się je stosuje?

Type guards to konstrukcja wykorzystywana do doprecyzowania typu w przypadku gdy zmienna może przyjmować więcej niż jeden typ:


const a: string | number = getValue();

function isNumber( a: string | number ): a is number {
  return typeof a === "number";
}

W tym przypadku getValue może zwrócić zarówno typ string jak i number. Użycie type guard pozwala zweryfikować jaki typ został użyty. Oczywiście możnaby tutaj pokusić się o skorzystanie tylko z operatora typeof, natomiast użycie type guard powoduje zapisanie informacji o typie zmiennej, przez co nie jest konieczne każdorazowe sprawdzanie jej typu.

9. Wymień główne korzyści jakie niesie ze sobą TypeScript.

Myślę, że to pytanie powinno paść jako jedno z pierwszych przy dyskusji o TypeScript, ponieważ rozbudowana odpowiedź na nie może zawierać odpowiedź również na inne pytania z tego wpisu.

Największą zaletą płynącą z TypeScriptu są oczywiście typy! Zarówno typy wbudowane jak i możliwość tworzenia własnych typów i interfejsów.

TypeScript oferuje naprawdę szeroki wachlarz możliwości w zakresie typowania co pozwala tworzyć o wiele czystszy kod niż w JavaScript. Typy pozwalają wyeleminować lub zredukować cały szereg błędów, które można popełnić w JavaScript, a na które nie pozwoli kompilator TypeScript. Kod w TS jest znacznie bardziej przewidywalny i łatwiejszy w debugowaniu.

Kolejna z zalet to domyślna implementacja eksperymentalnych funkcji tj. dekoratory czy optional chaining. Oprócz tego TypeScript oferuje modyfikatory dostępu, które w czystym JS są od niedawna.

TypeScript pozwala też na wykorzystywanie kodu JavaScript, co oznacza, że nie jest konieczne przepisywanie całego codebase od razu. Można to robić stopniowo.

Podsumowanie

Mam nadzieję, że dzięki tym pytaniom udało Ci się dowiedzieć czegoś nowego o TS. Jeśli na tej liście nie widzisz pytania z TypeScript, które Ci zadano, lub które uważasz że powinno się tu znaleźć, to napisz je koniecznie w komentarzu pod tym wpisem! Zachęcam też do zapoznania się ze źródłami i materiałami dodatkowymi.

Źródła i materiały dodatkowe