TypeScript zdobył oszałamiającą popularność w ostatnich latach, ale po co tak właściwie z niego korzystamy, jakie są jego plusy i minusy?

Zalety korzystania z TypeScript:

  • wcześniej wychwytywane błędy
  • przewidywalność działania dzięki statycznemu typowaniu
  • czytelność kodu
  • lepsze wsparcie edytora podczas developmentu
  • łatwiejszy i szybszy refaktoring
  • ograniczenie unit testów, przede wszystkim tych sprawdzających poprawność struktur

Wady stosowania kontroli typów:

  • dodatkowy czas potrzebny na otypowanie kodu
  • ograniczone zaufanie z uwagi na to, że koniec końców kod jest kompilowany do dynamicznego JS
  • kompilator typu complete, częściej false negative (mimo błędnego kodu kompilacja zakończona sukcesem) niż false positive (brak błędu, jednak kompilacja failuje)
  • narzut na czas kompilacji

TypeScript pozwala nam na sterowanie poziomem rygorystyczności. Jeżeli jesteśmy na etapie jego wdrażania w istniejącym już projekcie warto rozpocząć od od bardzo luźnej konfiguracji, natomiast startując z nowym projektem najlepiej skonfigurować go z poniższymi flagami:

  • strict
  • noUncheckedIndexAccess

Co w przypadku, gdy typ nie jest nam znany?

  • typ any – można z nim zrobić wszystko, brak kontroli typu, użycie bardzo niewskazane
  • typ unknown – nie można z nim zrobić nic, dopóki nie zastosujemy sprawdzenia rzeczywistego typu

Typy pomocnicze

Przykład kilku często bardzo ułatwiających typów pomocniczych dostępnych globalnie. Warto zapoznać się z całą listą dostępną w dokumentacji.

ReturnType
Otrzymujemy typ zwracany przez podany typ funkcji lub nazwę funkcji:

ReturnType<FunctionType>
type T = ReturnType<() => string>; // string

ReturnType<typeof functionName>
declare function fn(): { a: number; b: string };
type T = ReturnType<typeof fn>; // { a: number; b: string }

Partial
Otrzymujemy nowy typ którego wszystkie właściwości z typu źródłowego są opcjonalne:

Partial<T>
Partial<{name: string; point: number;}> // {name?: string; point?: number;}

Required
Typ wynikowy zawiera wszystkie właściwości z typu źródłowego jako wymagane:

Required<T>
Required<{name?: string; point?: number;}> // {name: string; point: number;}

Readonly
Otrzymujemy nowy typ którego wszystkie właściwości z typu źródłowego stają się readonly:

Readonly<T>
Readonly<{name: string;}> // {readonly name: string;}

Pick
Utworzony nowy typ zawiera jedynie właściwości wymienione jako drugi parametr:

Pick<T, Keys>
Pick<{name: string; point: number;}, "name"> // {name: string;}

Omit
Nowy typ powstaje z typu źródłowego z pominięciem właściwości wymienionych w drugim parametrze:

Omit<T, Keys>
Omit<{name: string; point: number;}, "name"> // {point: number;}

NonNullable
Tworzy nowy typ wyłączając z typu źródłowego null i undefined:

NonNullable<T>
NonNullable<string | number | undefined>; // string | number

Parameters
Wyciąga wszystkie parametry funkcji jako typ:

Parameters<T>
Parameters<(name: string, point: number) => void> // [name: string, point: number]

Dodatkowe ciekawostki:

Zwracanie this
Możliwe jest określenie zwracanego typu jako this:

class ExampleClass {
  method = (): this => {
    return this;
  }
}

const assertions
Celowe zawężenie literałów:

let x = "hello" as const; // '"hello"'
let y = [10, 20] as const; // 'readonly [10, 20]'
let z = { text: "hello" } as const; '{ readonly text: "hello" }'

Unie i przecięcia

Unie i przecięcia to jedne ze sposobów łączenia typów:

  • unia: A | B – typ wynikowy zawiera elementy obu zbiorów
  • przecięcie: A & B – typ wynikowy zawiera elementy wspólne obu zbiorów

Unie dyskryminacyjne

Jeżeli pracujemy na typie, który składa się z unii podtypów, to warto aby każdy z podtypów zawierał właściwość określającą dany typ. W poniższym przykładzie jest to state, dzięki któremu pracując na typie NetworkState możemy zaimplementować różną logikę wykorzystując switch lub if.

type NetworkLoadingState = {
  state: "loading";
};
type NetworkFailedState = {
  state: "failed";
  code: number;
};
type NetworkSuccessState = {
  state: "success";
  response: {
    title: string;
    duration: number;
    summary: string;
  };
};

type NetworkState =
 | NetworkLoadingState
 | NetworkFailedState
 | NetworkSuccessState;

Dodatkowo implementując Union Exhaustiveness check możemy mieć pewność, że obsłużyliśmy każdy przypadek:

function logger(s: NetworkState): string {
  switch (s.state) {
    case "loading":
      return "loading request";
    case "failed":
      return `failed with code ${s.code}`;
    case "success":
      return "got response";
    default:
      const _exhaustiveCheck: never = s;
      return s;
  }
}

W powyższym wypadku, gdyby typ NetworkState został w przyszłości rozszerzony o dodatkowy podtyp, TypeScript zwróciłby nam błąd informujący o tym, że nieobsłużony wariant który wpadnie w default nie jest typu never, więc nie może zostać przypisany do stałej _exhaustiveCheck.

Typeguards

Sposoby na sprawdzenie typu obiektu:

typeof

if (typeof arg === 'string') {
  // Within the block TypeScript knows that value must be a string
}

instanceof

if (arg instanceof ClassName) {
  // Within the block TypeScript knows that value must be an instance of ClassName
}

in

if (prop in object) {
  // Within the block TypeScript knows that property exists on an object
}

Literal type guard

if (s.state === "success") {
  // Within the block TypeScript can discriminate between union member
}

Custom type guard

function isClassName(arg: any): arg is ClassName {
  return (typeof arg.name === 'string');
}

Weak types

W przypadku, gdy interfejs posiada wszystkie pola typu optional jest postrzegany jako weak type. Jeżeli spróbujemy przypisać do takiego typu dane, które nie mają ani jednej wspólnej właściwości, to TypeScript zwróci błąd.

Generic constraints

Mamy możliwość napisania funkcji generycznej, której typy będą przez nas w pewien sposób ograniczone.

function merge<U extends {length: number;}, V extends object>(obj1: U, obj2: V)
function prop<T, K extends keyof T>(obj: T, key: K)

Conditional Types

Możemy wykorzystać również ternary operator znany z JavaScriptu, pozwalający za pomocą warunku stworzyć konretny typ.

SomeType extends OtherType ? TrueType : FalseType;
type Example = Dog extends Animal ? number : string;

infer keyword

Słowo kluczowe infer pozwala na wywnioskowanie typu z innego typu w ramach typu warunkowego.

type Foo<T> = T extends { a: infer U; b: infer U; } ? U : never;
Foo<{ a: string; b: string }>; // string
Foo<{ a: string; b: number }>; // string | number

type FlattenIfArray<T> = T extends (infer R)[] ? R : T
FlattenIfArray<string[]>; // string
FlattenIfArray<number>; // number

Zakończenie

Jeżeli chciałbyś dodać coś od siebie, zachęcam do dyskusji w komentarzach 🙂