Czym właściwie jest TypeScript i po co go używać
Rozszerzenie JavaScriptu, a nie nowy język
TypeScript to nadzbiór JavaScriptu. Oznacza to, że każdy poprawny kod JS jest też poprawnym kodem TS. Różnica polega na tym, że TypeScript dodaje system typów i kilka konstrukcji językowych, które potem są kompilowane do czystego JavaScriptu.
Praktycznie: piszesz pliki .ts (lub .tsx dla Reacta), a kompilator TypeScript generuje z nich pliki .js. Kod, który ostatecznie działa w przeglądarce lub Node.js, to nadal zwykły JavaScript – żaden silnik nie wykonuje TypeScriptu bezpośrednio.
Co daje system typów w TypeScript
System typów w TypeScript działa wyłącznie w czasie kompilacji. Wygodne jest to, że:
- wyłapuje literówki w nazwach właściwości obiektów,
- sprawdza zgodność argumentów funkcji z ich typami,
- pilnuje, aby funkcje zwracały zadeklarowane typy,
- ujawnia wiele błędów logiki jeszcze przed uruchomieniem aplikacji.
Jeśli w kodzie JS często trafiasz na błędy typu undefined is not a function albo cannot read property 'x’ of undefined, to TypeScript pomaga je wyeliminować dzięki statycznej analizie typów. Co ważne, działa to również przy korzystaniu z dużych bibliotek i frameworków – dobre definicje typów sprawiają, że edytor podpowiada właściwe API i ostrzega przed pomyłkami.
Jak zacząć pracę z TypeScriptem
Najprostsza ścieżka startowa wygląda tak:
- Zainstaluj TypeScript globalnie lub lokalnie w projekcie:
npm install typescript --save-dev. - Utwórz plik
tsconfig.jsonkomendąnpx tsc --init. - Dodaj pierwszy plik
index.tsi napisz kilka linii kodu. - Uruchom kompilator:
npx tsc(stworzy odpowiednie pliki.js).
W praktyce i tak najczęściej korzysta się z bundlerów (Vite, Webpack, esbuild) lub frameworków (Next.js, Angular), które mają wbudowaną obsługę TypeScriptu. Łatwo zapomnieć wtedy, że w tle działa kompilator TS – ale z punktu widzenia debugowania i zrozumienia błędów warto wiedzieć, że cały mechanizm sprowadza się właśnie do tsc i pliku konfiguracyjnego.

Podstawowe typy w TypeScript – fundament, którego nie warto pomijać
Typy prymitywne: string, number, boolean, null, undefined
TypeScript rozszerza istniejące w JS typy prymitywne, ale dodaje nad nimi kontrolę. Najważniejsze z nich:
- string – tekst, np.
"hello",'test',`template`, - number – liczba (zarówno całkowita, jak i zmiennoprzecinkowa),
- boolean – wartość logiczna
truelubfalse, - null – świadomy brak wartości,
- undefined – wartość niezainicjalizowana.
Przykładowe deklaracje:
let username: string = "Jan";
let age: number = 30;
let isAdmin: boolean = false;
let nothingHere: null = null;
let notSetYet: undefined = undefined;
Przy domyślnych ustawieniach kompilatora nie musisz zwykle deklarować typu explicite – kompilator wywnioskuje go z wartości początkowej. Jednak jawne typowanie przydaje się w bardziej złożonych strukturach, interfejsach i sygnaturach funkcji.
Tablice, krotki i obiekty – podstawowe kontenery
TypeScript pozwala dość precyzyjnie określić, co przechowuje tablica:
let numbers: number[] = [1, 2, 3];
let names: Array<string> = ["Ala", "Ola"];
Obie formy są równoważne, ale zapis number[] jest częściej spotykany w projektach front‑endowych.
Dla ściśle określonych struktur pozycyjnych przydają się krotki (tuples):
let userTuple: [string, number] = ["Jan", 30];
// userTuple[0] ma typ string, userTuple[1] ma typ number
Z krotkami wiąże się jedna z pierwszych pułapek TypeScriptu – kompilator bywa zbyt pobłażliwy przy metodach takich jak push. Przy domyślnej konfiguracji pozwoli na:
userTuple.push(123); // często brak błędu w czasie kompilacji!
Z tego powodu krotki lepiej traktować jako opis struktury wejściowej / wyjściowej (np. wyniki funkcji), a nie elastyczną strukturę do modyfikacji w wielu miejscach kodu.
Typ any, unknown i never – ważne różnice
W systemie typów TS są trzy szczególne typy, które warto rozróżniać:
- any – wyłącza sprawdzanie typów,
- unknown – typ nieznany, ale bezpieczniejszy niż
any, - never – wartość, która nigdy nie występuje (np. funkcja rzucająca wyjątek).
Przykłady:
let anything: any = "tekst";
anything = 123; // OK
anything.toFixed(); // OK w oczach TS, ale może wybuchnąć w runtime
let value: unknown = "tekst";
// value.toUpperCase(); // BŁĄD kompilacji – musisz zawęzić typ
if (typeof value === "string") {
value.toUpperCase(); // OK, po zawężeniu
}
function fail(message: string): never {
throw new Error(message);
}
any to wygodne, ale ryzykowne obejście systemu typów. Używany bez kontroli prowadzi do sytuacji, w której TypeScript staje się tylko kolorową nakładką na JS, bez realnych korzyści. unknown natomiast wymusza dodatkowe sprawdzenia i jest rozsądniejszym wyborem do modelowania „czarnych skrzynek” (np. danych z zewnętrznego API o niepewnej strukturze).

System typów w praktyce: unie, typy literalne i aliasy
Typy unijne: jeden z wielu możliwych typów
Typ unijny (union type) pozwala wskazać, że wartość może mieć jeden z kilku typów. Składnia jest prosta:
let id: string | number;
id = "abc-123";
id = 42;
// id = false; // BŁĄD – boolean nie należy do unii
Unia świetnie opisuje sytuacje takie jak:
- pola, które mogą być
stringlubnull, - statusy zwracane przez API:
"success" | "error" | "pending", - identyfikatory, które w różnych systemach bywają liczbami lub tekstem.
Wspólnie z unią TypeScript wykorzystuje tzw. narrowing (zawężanie typów) poprzez instrukcje warunkowe:
function printId(id: string | number) {
if (typeof id === "string") {
console.log(id.toUpperCase());
} else {
console.log(id.toFixed(2));
}
}
Komunikaty o błędach w stylu „Object is possibly 'undefined’” wynikają często z unii z udziałem undefined. Można je opanować, wykorzystując albo kontrolowane sprawdzenia, albo odpowiednie opcje kompilatora (np. strictNullChecks).
Typy literalne i wzorce „string enums”
Typ literalny ogranicza wartość do konkretnej stałej, np.:
type Direction = "up" | "down" | "left" | "right";
let dir: Direction;
dir = "up"; // OK
dir = "left"; // OK
// dir = "top"; // BŁĄD – "top" nie należy do Direction
Typy literalne szczególnie dobrze sprawdzają się przy:
- statusach:
"idle" | "loading" | "success" | "error", - trybach widoku:
"list" | "grid", - rodzajach zdarzeń:
"click" | "hover" | "submit".
Często zastępują klasyczne enum. Wiele projektów z premedytacją rezygnuje z enum na rzecz unii typów literalnych ze względu na prostszy output JS i mniejszą liczbę pułapek (szczególnie przy numeric enumach). Dobrym wzorcem jest także trzymanie wartości w obiekcie i wyprowadzanie typu literalnego za pomocą as const:
const STATUSES = {
IDLE: "idle",
LOADING: "loading",
SUCCESS: "success",
ERROR: "error"
} as const;
type Status = typeof STATUSES[keyof typeof STATUSES];
W efekcie uzyskujemy zarówno stabilne stałe, jak i typ będący unią ich wartości.
Alias typu: type zamiast przepisywania
Alias typu (słowo kluczowe type) to po prostu nazwa dla określonej kombinacji typów:
type UserId = string | number;
type Point = { x: number; y: number };
let userId: UserId;
let position: Point;
Alias jest szczególnie przydatny, gdy:
- zapisujesz rozbudowaną unię typów (np. wiele literalnych statusów),
- definiujesz typy funkcyjne (np.
type OnClick = (e: MouseEvent) => void), - chcesz ponownie wykorzystać strukturę w wielu miejscach, ale nie potrzebujesz semantyki interfejsu.
W kontekście pułapek warto znać różnicę między type a interface – nie są jednoznacznie wymienne i działają nieco inaczej przy rozszerzaniu i łączeniu (o tym później). Dobrą praktyką jest używanie aliasów dla typów unii i złożonych kombinacji oraz interfejsów dla kształtów obiektów, modeli domenowych i struktur danych.
Interfejsy w TypeScript: definicja, rozszerzanie i typowanie obiektów
Podstawowa definicja interfejsu
Interfejs to opis kształtu obiektu – czyli jakie ma właściwości, jakiego typu są te właściwości i które są wymagane. Przykład:
interface User {
id: number;
name: string;
email: string;
isAdmin: boolean;
}
const user: User = {
id: 1,
name: "Jan",
email: "jan@example.com",
isAdmin: false,
};
Jeśli spróbujesz pominąć jakąś właściwość lub dodać nieistniejącą, kompilator zgłosi błąd. W praktyce to pierwsza linia obrony przed przypadkowym „rozsypaniem się” struktur danych przy refaktoryzacji dużego kodu.
Właściwości opcjonalne i tylko do odczytu
Nie wszystkie pola muszą być obowiązkowe. Służy do tego znak zapytania po nazwie właściwości:
interface UserProfile {
id: number;
name: string;
avatarUrl?: string; // może być string albo undefined
}
Kompilator wymusi wtedy obsługę przypadku, w którym pole jest nieokreślone:
function getAvatar(profile: UserProfile) {
if (profile.avatarUrl) {
return profile.avatarUrl;
}
return "/default-avatar.png";
}
Dostępne są także właściwości readonly – można je ustawić przy tworzeniu obiektu, ale nie można ich zmienić później:
interface Config {
readonly apiUrl: string;
timeout: number;
}
const config: Config = {
apiUrl: "/api",
timeout: 5000,
};
// config.apiUrl = "/new-api"; // BŁĄD – pole tylko do odczytu
Readonly jest bardzo przydatne przy modelowaniu konfiguracji, stałych domenowych, wartości pochodzących z zewnętrznych serwisów, które nie powinny być modyfikowane w runtime.
Rozszerzanie interfejsów i dziedziczenie kształtów
Interfejsy można łączyć i rozszerzać. Jeśli masz bazowy kształt obiektu i chcesz opisać jego bardziej szczegółową wersję, używasz extends:
interface Person {
id: number;
name: string;
}
interface Employee extends Person {
position: string;
salary: number;
}
const emp: Employee = {
id: 1,
name: "Anna",
position: "Developer",
salary: 10000,
};
Dzięki temu struktury są spójne, a zmiana w jednym miejscu (np. dodanie pola surname do Person) rozlewa się po całym kodzie i TypeScript pomaga wychwycić miejsca, które trzeba dostosować. Przy większych projektach to często jedyny rozsądny sposób kontrolowania zmian modeli.
Interfejsy można też łączyć wielokrotnie, co przypomina wielokrotne dziedziczenie – z tym że dotyczy tylko statycznego kształtu danych:
Wielokrotne rozszerzanie i konflikty w interfejsach
Rozszerzanie nie musi ograniczać się do jednego interfejsu. Można skleić kilka kształtów w jeden, co przydaje się np. przy typowaniu obiektów w warstwie UI (połączenie danych domenowych z metadanymi komponentu):
interface WithTimestamps {
createdAt: Date;
updatedAt: Date;
}
interface WithSoftDelete {
deletedAt?: Date;
}
interface Article extends WithTimestamps, WithSoftDelete {
id: number;
title: string;
content: string;
}
Taki wzorzec ułatwia ponowne wykorzystanie logiki – gdy wszystkie encje w systemie mają znaczniki czasu, nie ma potrzeby dopisywać ich za każdym razem ręcznie.
Konflikt pojawia się, gdy dwa rozszerzane interfejsy deklarują to samo pole o niezgodnych typach:
interface A {
status: "active" | "inactive";
}
interface B {
status: "pending";
}
interface C extends A, B {} // BŁĄD – status nie może być jednocześnie A i B
Takie sytuacje lepiej rozwiązywać wcześniej na poziomie modelu domeny, zamiast liczyć na „magiczny” kompromis TS. System typów jasno wskazuje, że te dwie koncepcje trzeba połączyć lub przebudować.
Interfejs vs alias typu dla obiektów
Ten sam kształt obiektu można zapisać jako interface albo jako alias typu:
interface UserInterface {
id: number;
name: string;
}
type UserType = {
id: number;
name: string;
};
Na pierwszy rzut oka efekt jest identyczny, ale różnice wychodzą przy rozbudowanych modelach.
- Interfejs można rozszerzać (
extends) i deklaratywnie „nadpisywać” (merging deklaracji). - Alias typu jest bardziej elastyczny – może reprezentować nie tylko obiekt, ale też unię, funkcyjny typ itp.
Interfejsy wspierają tzw. declaration merging:
interface Settings {
theme: "light" | "dark";
}
interface Settings {
locale: string;
}
const s: Settings = {
theme: "dark",
locale: "pl-PL",
};
Dwie deklaracje Settings łączą się w jedną. Dla aliasów taki zabieg jest niedozwolony – podwójna deklaracja type Settings = ... spowoduje błąd. To z jednej strony ograniczenie, z drugiej – ochrona przed przypadkowym nadpisaniem typu.
Indeksy, mapy i dynamiczne właściwości w interfejsach
Często trzeba opisać obiekty o dynamicznych kluczach, np. mapę tłumaczeń lub słownik konfiguracji. Służą do tego sygnatury indeksowe:
interface TranslationMap {
[key: string]: string;
}
const translations: TranslationMap = {
hello: "Cześć",
goodbye: "Do widzenia",
};
Każde pole o kluczu typu string musi być wtedy stringiem. Można też wskazać bardziej szczegółowy typ klucza:
interface ErrorMessages {
[code: number]: string;
}
const errors: ErrorMessages = {
404: "Not found",
500: "Server error",
};
Jeśli interfejs ma zarówno pola nazwane, jak i indeks, trzeba pilnować zgodności typów – każde nazwane pole musi być podtypem typu indeksowego:
interface SettingsByKey {
[key: string]: string | number;
theme: string; // OK – string pasuje
retryCount: number; // OK – number pasuje
// enabled: boolean; // BŁĄD – boolean nie należy do string | number
}
Interfejsy dla funkcji i obiektów funkcyjnych
Interfejs nie opisuje wyłącznie „zwykłych” obiektów z polami. Można nim typować funkcje lub obiekty posiadające zarówno sygnaturę wywołania, jak i właściwości:
interface StringFormatter {
(value: string): string; // sygnatura funkcji
prefix: string; // dodatkowa właściwość
}
const formatter: StringFormatter = (value: string) => {
return formatter.prefix + value.toUpperCase();
};
formatter.prefix = "[LOG] ";
formatter("test"); // "[LOG] TEST"
Przy typowaniu prostych funkcji częściej stosuje się aliasy:
type Predicate<T> = (value: T) => boolean;
const isPositive: Predicate<number> = (n) => n > 0;
Interfejs z sygnaturą funkcji przydaje się przede wszystkim tam, gdzie trzeba rozszerzać taki typ lub łączyć go z innymi kształtami obiektu.
Typowanie tablic i krotek: Array, ReadonlyArray, tuple
Tablice można opisać na dwa równoważne sposoby:
const numbers: number[] = [1, 2, 3];
const names: Array<string> = ["Jan", "Anna"];
W praktyce składnia number[] jest czytelniejsza, zwłaszcza przy złożonych typach generycznych. Dodatkowo istnieje wersja niemutowalna:
const readonlyNumbers: ReadonlyArray<number> = [1, 2, 3];
// readonlyNumbers.push(4); // BŁĄD – tablica tylko do odczytu
Do modelowania struktur o stałej długości i kolejności typów służą krotki:
type Point2D = [number, number];
const p: Point2D = [10, 20];
// const bad: Point2D = [10]; // BŁĄD – za mało elementów
// const bad2: Point2D = [10, "x"]; // BŁĄD – drugi element nie jest number
Krotki świetnie sprawdzają się np. przy współrzędnych, parach wynik–błąd, zwracaniu kilku wartości z funkcji bez konieczności budowania obiektu:
function parseIntSafe(value: string): [number | null, Error | null] {
const result = Number.parseInt(value, 10);
if (Number.isNaN(result)) {
return [null, new Error("Invalid number")];
}
return [result, null];
}
Generyki: typy parametryzowane
Generyki (generics) pozwalają pisać funkcje, interfejsy i typy, które działają z wieloma typami danych, zachowując silne typowanie. Przykładowy, bardzo prosty generyk:
function identity<T>(value: T): T {
return value;
}
identity<number>(10); // T = number
identity("abc"); // T = string – wywnioskowany automatycznie
Ten sam mechanizm można zastosować w interfejsach:
interface ApiResponse<T> {
data: T;
status: number;
error?: string;
}
type User = { id: number; name: string };
const response: ApiResponse<User> = {
data: { id: 1, name: "Jan" },
status: 200,
};
Zamiast kopiować strukturę ApiResponse dla każdego modelu, parametr T robi to za nas, wymuszając zgodność kształtów danych.
Ograniczenia generyków: extends i domyślne parametry
Parametry typów można ograniczać, aby nie przyjmowały „czegokolwiek”. Służy do tego extends:
function getLength<T extends { length: number }>(value: T): number {
return value.length;
}
getLength("tekst"); // OK – string ma length
getLength([1, 2, 3]); // OK – tablica ma length
// getLength(123); // BŁĄD – number nie ma length
Drugi krok to domyślne parametry typów, definiowane znakiem =:
interface PaginatedResponse<T = any> {
items: T[];
total: number;
}
const resp1: PaginatedResponse = {
items: [],
total: 0,
}; // T = any
const resp2: PaginatedResponse<User> = {
items: [{ id: 1, name: "Jan" }],
total: 1,
}; // T = User
Domyślne parametry pomagają uprościć API bibliotek lub wewnętrznych helperów – w typowych przypadkach można pominąć typ, a w bardziej wrażliwych częściach kodu podać go explicite.
Typy warunkowe: logika if/else w systemie typów
TypeScript pozwala tworzyć typy zależne od innych typów przy pomocy tzw. typów warunkowych:
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
Praktyczny przykład to wyciągnięcie typu pola z obiektu:
type PropertyType<T, K extends keyof T> = T[K];
interface User {
id: number;
name: string;
}
type UserIdType = PropertyType<User, "id">; // number
type UserNameType = PropertyType<User, "name">; // string
Typy warunkowe są fundamentem wielu utili z lib.d.ts i bibliotek, np. ReturnType<T> czy InstanceType<T>. Własne konstrukcje warto wprowadzać ostrożnie – ułatwiają życie, ale przy zbyt wyszukanej logice utrudniają zrozumienie kodu innym osobom w zespole.
Operatory na typach: keyof, typeof, indexed access
System typów TS udostępnia kilka operatorów, które dają dostęp do „metadanych” o innych typach:
keyof T– unia nazw pól typuT,typeof value– typ wywnioskowany na podstawie istniejącej zmiennej lub wartości,T[K]– tzw. indexed access type, czyli typ właściwościKw obiekcieT.
W połączeniu pozwalają zbudować silnie typowane helpery, np. bezpieczny getter właściwości:
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "Jan" };
const id = getProp(user, "id"); // typ: number
const name = getProp(user, "name"); // typ: string
// getProp(user, "email"); // BŁĄD – "email" nie jest kluczem user
Jedna z praktycznych sztuczek w kodzie aplikacji: wyprowadzenie typu z istniejącej stałej konfiguracyjnej:
const ROLES = ["user", "admin", "editor"] as const;
type Role = (typeof ROLES)[number]; // "user" | "admin" | "editor"
Wbudowane typy narzędziowe (utility types)
W standardowej bibliotece TS znajduje się kilka generyków, które rozwiązują typowe problemy bez konieczności pisania własnych konstrukcji. Najczęściej używane:
Partial<T>– wszystkie pola opcjonalne,Required<T>– wszystkie pola obowiązkowe,Readonly<T>– wszystkie pola tylko do odczytu,Pick<T, K>– wybór wybranych pól,Omit<T, K>– usunięcie wybranych pól,Record<K, T>– mapa klucz–wartość.
Przykłady z życia w aplikacji webowej:
interface User {
id: number;
name: string;
email: string;
isAdmin: boolean;
}
// podczas tworzenia użytkownika backend generuje id i isAdmin:
type CreateUserDto = Omit<User, "id" | "isAdmin">;
// podczas aktualizacji wszystkie pola są opcjonalne:
type UpdateUserDto = Partial<CreateUserDto>;
// mapowanie ról na opis:
type Role = "user" | "admin" | "editor";
type RoleDescriptions = Record<Role, string>;
const roleDescriptions: RoleDescriptions = {
user: "Zwykły użytkownik",
admin: "Administrator systemu",
editor: "Redaktor treści",
};
Używanie tych utili porządkuje modele DTO i eliminuje ręczne tworzenie niemal identycznych interfejsów różniących się jednym czy dwoma polami.
Pułapki strukturalnego systemu typów
TypeScript ma strukturalny, a nie nominalny system typów. Liczy się kształt obiektu, a nie nazwa typu. Dwa typy są zgodne, jeśli mają kompatybilne pola, nawet jeśli nazywają się inaczej:
interface Point2D {
x: number;
y: number;
}
interface Vector2D {
x: number;
y: number;
}
const p: Point2D = { x: 10, y: 20 };
const v: Vector2D = p; // OK – kształt jest ten sam
To wygodne, ale bywa zdradliwe. Przykład: dwa różne identyfikatory o tej samej reprezentacji:
type UserId = number;
type OrderId = number;
function getUser(id: UserId) {}
function getOrder(id: OrderId) {}
const userId: UserId = 1;
getOrder(userId); // OK w oczach TS, choć semantycznie to pomyłka
Jeśli trzeba nadać typom tożsamość, można skorzystać z tzw. nominal typing via branding:
Nominalne znacznikowanie typów (branding)
Nominalne „znacznikowanie” polega na dodaniu do typu pola, którego zwykły kod nigdy nie użyje, ale które odróżnia go w systemie typów od innych, pozornie takich samych. Najprostszy wzorzec:
type Branded<T, Brand extends string> = T & { __brand: Brand };
type UserId = Branded<number, "UserId">;
type OrderId = Branded<number, "OrderId">;
function asUserId(id: number): UserId {
return id as UserId;
}
function asOrderId(id: number): OrderId {
return id as OrderId;
}
function getUser(id: UserId) {}
function getOrder(id: OrderId) {}
const userId = asUserId(1);
// getOrder(userId); // BŁĄD – UserId nie jest OrderId
W runtime pole __brand nie istnieje – to jedynie konstrukcja w systemie typów. Zazwyczaj dodaje się też fabryki (asUserId, asOrderId), aby unikać rozrzucania rzutowań as po całym projekcie.
Ten wzorzec dobrze sprawdza się przy identyfikatorach, walutach, jednostkach fizycznych i wszędzie tam, gdzie „gołe” liczby lub stringi łatwo pomylić.
Funkcje przeciążone a unie – dwa sposoby na tę samą logikę
Przy API, które działa różnie w zależności od argumentów, pojawia się wybór: przeciążenia funkcji (overloads) czy unie typów? Przeciążenia deklaruje się jako kilka sygnatur nad jedną implementacją:
function format(input: string): string;
function format(input: number): string;
function format(input: Date): string;
// implementacja musi obejmować wszystkie przypadki
function format(input: string | number | Date): string {
if (typeof input === "string") {
return input.trim();
}
if (typeof input === "number") {
return input.toFixed(2);
}
return input.toISOString();
}
const s = format(" test "); // string
const n = format(123); // string
const d = format(new Date()); // string
Klient funkcji widzi jedynie sygnatury przeciążenia, nie implementację – dlatego w deklaracjach można opisać różne zwracane typy zależne od wejścia, a w implementacji i tak używa się ich unii.
Alternatywa to jedna sygnatura z unią i typami warunkowymi:
type Formattable = string | number | Date;
function format2(input: Formattable): string {
// identyczna implementacja jak wyżej
// ...
return "";
}
Przeciążenia sprawdzają się, gdy typ wyniku naprawdę zależy od argumentów i chcesz, żeby IntelliSense to odzwierciedlał. Unie typów są prostsze tam, gdzie wynik jest jeden, a zmienia się tylko logika środka.
Unie rozłączne i wąskie typowanie (discriminated unions)
Bardzo mocną stroną TS są unie rozłączne – typy, które są zbiorem kilku „wariantów” rozpoznawanych po wspólnym polu rozróżniającym:
type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: string };
type ErrorState = { status: "error"; error: Error };
type RequestState = LoadingState | SuccessState | ErrorState;
function handleState(state: RequestState) {
switch (state.status) {
case "loading":
// state: LoadingState
break;
case "success":
// state: SuccessState – masz dostęp do state.data
console.log(state.data.toUpperCase());
break;
case "error":
// state: ErrorState – masz dostęp do state.error
console.error(state.error.message);
break;
default:
// tu TS wymusi exhaustiveness check, jeśli włączysz noUncheckedIndexedAccess/strict
const _never: never = state;
return _never;
}
}
W projekcie front-endowym taki wzorzec porządkuje obsługę zapytań HTTP, stanów formularzy, maszyn stanów. W back-endzie pojawia się przy komunikatach kolejek, jobach CRON czy zdarzeniach domenowych.
Kluczem jest pole rozróżniające (tu: status) o typie unii literałów – "loading" | "success" | "error". Dzięki niemu TS potrafi automatycznie zawężać typ w gałęziach switch i if.
Type guards: własne strażniki typów
TypeScript ma wbudowane mechanizmy zawężania typów oparte na JavaScriptowych operatorach (typeof, instanceof, sprawdzanie obecności pola). Można jednak tworzyć własne „strażniki typów”, które pomagają w pracy z unią typów lub niebezpiecznymi danymi (np. z JSON-a).
interface User {
id: number;
name: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value
);
}
function greetMaybeUser(value: unknown) {
if (isUser(value)) {
// wewnątrz tego bloku typ value to User
console.log("Cześć,", value.name);
} else {
console.log("Nieznany typ danych");
}
}
Zwróć uwagę na sygnaturę value is User. To specjalna forma typu zwracanego, która mówi TS-owi: „jeśli funkcja zwróciła true, możesz traktować argument jako User”.
Strażniki przydają się też do pracy z unią rozłączną:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number };
function isCircle(shape: Shape): shape is Extract<Shape, { kind: "circle" }> {
return shape.kind === "circle";
}
function area(shape: Shape): number {
if (isCircle(shape)) {
return Math.PI * shape.radius ** 2;
}
return shape.size * shape.size;
}
Typy „niebezpieczne”: any, unknown, never
W praktyce trzy szczególne typy często pojawiają się w dyskusjach o jakości kodu TS. Każdy ma sens, ale wymaga rozsądnego użycia.
any – wyjście awaryjne
any wyłącza sprawdzanie typów. Na takiej wartości można zrobić wszystko:
let value: any = getFromLegacyLib();
value.trim(); // OK w typach, choć może się wysypać w runtime
value.nonExistent(); // też OK – TS nic nie zgłasza
Czasem jest to potrzebne, np. przy integracji ze starą, nietypowaną biblioteką albo przy szybkim prototypie. Problem pojawia się wtedy, gdy any zaczyna „zarażać” kolejne fragmenty typów – generuje się z niego ApiResponse<any>, trafia do modeli domenowych, po czym błędy z runtime znowu zaczynają przedzierać się do produkcji.
W nowym kodzie lepiej użyć unknown, a any zostawić na bardzo wąskie, świadomie opisane fragmenty (często z komentarzem).
unknown – bezpieczny odpowiednik any
unknown mówi: „naprawdę nie wiadomo, co to jest”, ale jednocześnie zmusza do sprawdzenia typu przed użyciem:
function handle(value: unknown) {
// value.trim(); // BŁĄD – TS nie wie, czy jest stringiem
if (typeof value === "string") {
console.log(value.trim()); // OK – zawężenie
} else if (typeof value === "number") {
console.log(value.toFixed(2)); // OK
}
}
Dla wszystkich wejść z zewnątrz (API, localStorage, JSON.parse, web sockety) lepszym punktem startu jest unknown plus walidacja/strażnik typów niż automatyczne any.
never – typ niemożliwy
never to typ wartości, które nigdy nie występują. Zjawia się w kilku miejscach:
- w funkcjach, które zawsze rzucają wyjątek lub nigdy się nie kończą,
- w „niedostępnych” gałęziach unii rozłącznej,
- jako wynik błędnie skonstruowanego typu warunkowego.
function fail(message: string): never {
throw new Error(message);
}
function loopForever(): never {
while (true) {}
}
Najbardziej praktyczne zastosowanie to wymuszanie pełnego pokrycia przypadków:
type Status = "loading" | "success" | "error";
function exhaustiveCheck(status: Status) {
switch (status) {
case "loading":
case "success":
case "error":
break;
default:
const _exhaustive: never = status;
return _exhaustive;
}
}
Jeśli ktoś doda nową wartość do unii ("canceled"), TS zgłosi błąd w miejscu przypisania do never.
Typy funkcji, this i pułapki kontekstu
Funkcje w TypeScript też mają swoje typy – nie tylko argumentów i wyniku, ale też kontekstu this. Najprostsza forma:
type UnaryFn<T, R> = (arg: T) => R;
const toString: UnaryFn<number, string> = (n) => n.toString();
Problemy zaczynają się przy funkcjach, które polegają na this (np. w starszym kodzie z prototypami lub w niektórych bibliotekach). TS pozwala jawnie opisać typ this:
interface Counter {
value: number;
increment(this: Counter): void;
}
const counter: Counter = {
value: 0,
increment() {
this.value++;
},
};
counter.increment(); // OK
const inc = counter.increment;
// inc(); // BŁĄD – this: Counter nie jest zapewnione
Dzięki temu TS pilnuje, by takie funkcje były wywoływane w poprawnym kontekście. W aplikacjach React/Node wygodniej jest bazować na funkcjach strzałkowych (które „zamrażają” this z otaczającego zakresu), ale w integracjach z cudzym kodem jawny typ this potrafi uchronić przed trudnym do znalezienia błędem.
Infer: wyciąganie typów wewnątrz typów warunkowych
Słowo kluczowe infer służy do „wyłuskania” fragmentu typu w typie warunkowym. Przykład z funkcją:
type ReturnTypeOf<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: 1, name: "Jan" };
}
type GetUserReturn = ReturnTypeOf<typeof getUser>;
// { id: number; name: string }
Można w ten sposób wyciągać:
- typ elementu tablicy,
- typ wyniku Promise,
- typ parametru konkretnej funkcji.
type ElementType<T> = T extends (infer U)[] ? U : T;
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type A = ElementType<number[]>; // number
type B = UnwrapPromise<Promise<string>>; // string
W „codziennym” kodzie często wystarcza gotowe ReturnType<T>, Awaited<T> itp. Własne utilsy z infer przydają się, gdy budujesz wewnętrzne API (np. system pluginów) i chcesz, by typy konfiguracyjne były wyprowadzane automatycznie z centralnej definicji.
Moduły, deklaracje i separacja typów od implementacji
Przy większym kodzie pojawia się pytanie: jak układać typy, żeby nie zrobić z projektu śmietnika types.ts z tysiącem powiązań? Kilka praktycznych zasad:
- trzymaj typy blisko implementacji, dopóki nie muszą być współdzielone,
- udostępniaj na zewnątrz tylko to, co jest elementem „publicznego” API modułu,
- dla modułów wielokrotnego użytku rozważ
.d.ts(deklaracje) albo osobny pakiet z typami.
Prosty przykład rozdzielenia warstwy publicznej od szczegółów:
// user.model.ts
export interface User {
id: number;
name: string;
email: string;
}
// user.service.ts
import type { User } from "./user.model";
export interface UserService {
getById(id: number): Promise<User | null>;
create(data: Pick<User, "name" | "email">): Promise<User>;
}
Słowo kluczowe import type sprawia, że import znika z wygenerowanego JavaScriptu i jest wykorzystywany wyłącznie podczas sprawdzania typów. Przydaje się to, gdy chcesz uniknąć cyklicznych zależności na poziomie runtime lub minimalizować bundla.
Pułapki w konfiguracji kompilatora i tryb strict
Nawet najlepsze typy nie pomogą, jeśli kompilator ma zbyt luźne ustawienia. Kluczowe opcje w tsconfig.json:
"strict": true– włącza zestaw ostrzejszych reguł,"noImplicitAny": true– zakazuje niejawnegoany,"strictNullChecks": true– wymaga jawnej obsługinulliundefined,"strictFunctionTypes": true– dokładniejsze sprawdzanie typów funkcji,"noUncheckedIndexedAccess": true– wymusza sprawdzenie dostępu przez indeks.
Najczęściej zadawane pytania (FAQ)
Czym jest TypeScript i czym różni się od JavaScript?
TypeScript to nadzbiór JavaScriptu, czyli rozszerzenie języka JS o system typów i kilka dodatkowych konstrukcji. Każdy poprawny kod JavaScript jest jednocześnie poprawnym kodem TypeScript.
W praktyce piszesz kod w plikach .ts (lub .tsx dla Reacta), a kompilator TypeScript (tsc) zamienia go na zwykły JavaScript (.js). Przeglądarka ani Node.js nie uruchamiają TypeScriptu bezpośrednio – zawsze wykonują wyłącznie wynikowy kod JS.
Po co używać TypeScriptu, skoro mogę pisać w czystym JavaScript?
TypeScript pomaga wychwycić błędy jeszcze przed uruchomieniem aplikacji. System typów działa w czasie kompilacji i m.in.:
- sprawdza zgodność typów argumentów funkcji i wartości zwracanych,
- wyłapuje literówki w nazwach właściwości obiektów,
- zmniejsza liczbę błędów typu
undefined is not a functionczycannot read property 'x' of undefined.
Dodatkowo lepsze podpowiedzi w edytorze (IntelliSense) znacząco przyspieszają pracę, zwłaszcza z dużymi bibliotekami i frameworkami.
Jak zacząć naukę TypeScriptu krok po kroku?
Najprostszy start wygląda tak:
- zainstaluj TypeScript w projekcie:
npm install typescript --save-dev, - utwórz konfigurację:
npx tsc --init(powstanie pliktsconfig.json), - dodaj plik
index.tsi napisz kilka linii kodu, - uruchom kompilator:
npx tsc, który wygeneruje odpowiadające mu pliki.js.
W realnych projektach często używa się bundlerów (Vite, Webpack, esbuild) lub frameworków (Next.js, Angular), które integrują TypeScript „w tle”, ale pod spodem i tak działa zwykły kompilator tsc.
Jakie są podstawowe typy w TypeScript i jak je deklarować?
Podstawowe typy prymitywne w TypeScript to: string, number, boolean, null, undefined. Przykładowe deklaracje:
let username: string = "Jan";
let age: number = 30;
let isAdmin: boolean = false;
Przy domyślnych ustawieniach kompilator często sam wywnioskuje typ z wartości początkowej, ale jawne typowanie jest bardzo przydatne przy bardziej złożonych strukturach, interfejsach oraz sygnaturach funkcji.
Jaka jest różnica między typami any, unknown i never?
any praktycznie wyłącza sprawdzanie typów – możesz przypisać dowolną wartość i wywołać na niej dowolną metodę, co łatwo prowadzi do błędów w runtime. To „ucieczka” z systemu typów i należy go używać bardzo ostrożnie.
unknown oznacza typ nieznany, ale bezpieczny – dopóki nie sprawdzisz typu (np. przez typeof lub instanceof), kompilator nie pozwoli wywołać metod ani używać wartości w niezgodny sposób. never opisuje sytuacje, w których wartość nigdy nie występuje, np. funkcja rzucająca wyjątek lub nieskończona pętla.
Czym jest typ unijny (union) w TypeScript i do czego się go używa?
Typ unijny pozwala określić, że zmienna może mieć jeden z kilku typów, np. string | number. Przykład: let id: string | number; pozwala przypisać zarówno tekst "abc-123", jak i liczbę 42, ale np. false spowoduje błąd kompilacji.
Uniony dobrze opisują m.in. statusy zwracane przez API (np. "success" | "error" | "pending"), pola mogące przyjmować string lub null, czy identyfikatory które bywają liczbami lub tekstem. TypeScript potrafi zawężać taki typ w warunkach (if (typeof id === "string") { ... }), dzięki czemu kod pozostaje bezpieczny typowo.
Dlaczego krotki (tuples) w TypeScript mogą być pułapką?
Krotki pozwalają opisać strukturę pozycyjną, np. [string, number] dla pary ["Jan", 30]. Problem w tym, że przy domyślnych ustawieniach kompilator bywa zbyt pobłażliwy dla metod takich jak push i może nie zgłosić błędu przy dodaniu elementu innego typu lub w niewłaściwej pozycji.
Dlatego krotki najlepiej traktować jako opis struktury wejściowej/wyjściowej (np. wynik funkcji), a nie elastyczną strukturę, którą często modyfikujemy w kodzie. Do dynamicznych kolekcji lepiej nadają się zwykłe tablice z jasno określonym typem elementów, np. number[].
Esencja tematu
- TypeScript jest nadzbiorem JavaScriptu – każdy poprawny kod JS jest poprawnym kodem TS, a ostatecznie zawsze uruchamiany jest zwykły JavaScript.
- System typów TypeScripta działa tylko w czasie kompilacji, ale pozwala wcześnie wyłapywać błędy (literówki, niezgodne argumenty funkcji, złe typy zwrotów) i znacząco poprawia jakość kodu.
- Start z TypeScriptem jest prosty: instalacja pakietu, wygenerowanie tsconfig.json, dodanie plików .ts i kompilacja przez tsc – w praktyce często ukryte za bundlerami i frameworkami.
- Podstawowe typy prymitywne (string, number, boolean, null, undefined) oraz tablice, krotki i obiekty stanowią fundament – jawne typowanie szczególnie pomaga przy bardziej złożonych strukturach i funkcjach.
- Krotki (tuples) dobrze opisują struktury pozycyjne, ale przez pobłażliwość kompilatora przy metodach takich jak push lepiej używać ich głównie jako typów wejścia/wyjścia funkcji, a nie jako elastycznych struktur do modyfikacji.
- Typy any, unknown i never pełnią różne role: any wyłącza sprawdzanie typów i bywa ryzykowny, unknown wymusza bezpieczne zawężanie, a never opisuje wartości, które nigdy nie występują (np. funkcje zawsze rzucające wyjątek).
- Typy unijne (union types) umożliwiają precyzyjne modelowanie wartości mogących przyjmować kilka form (np. string | number, statusy API), a mechanizm zawężania typów pozwala pisać bezpieczniejszy i czytelniejszy kod.






