TypeScript
Głęboka semantyka systemu typów TypeScript 5.x: typowanie strukturalne, zawężanie, unie dyskryminowane, generyki, typy warunkowe, mapowane i szablonowe.
- Structural typing vs nominal typing i konsekwencje dla API
- unknown vs any vs never, kiedy który jest poprawny
- Discriminated unions + exhaustiveness checking przez never
- Generics z constraints, conditional types i infer
- satisfies operator vs adnotacja typu vs as
- Mapped types z key remapping (as clause) i utility types
1 · Typowanie strukturalne i podstawy systemu typów
★ egzaminTypeScript nie sprawdza typów po nazwie deklaracji, tylko po kształcie danych. To fundament odróżniający go od Javy czy C#, i źródło większości zaskoczeń u programistów przychodzących z języków nominalnie typowanych.
interface Point2D {
x: number;
y: number;
}
interface Vector {
x: number;
y: number;
}
function length(p: Point2D): number {
return Math.sqrt(p.x ** 2 + p.y ** 2);
}
const v: Vector = { x: 3, y: 4 };
length(v); // OK, Vector ma dokładnie kształt Point2D, nazwa nie ma znaczenia
// Excess property check: literał obiektowy jest sprawdzany ściślej niż zmienna
length({ x: 3, y: 4, z: 0 }); // Error: 'z' nie istnieje w Point2D
const withZ = { x: 3, y: 4, z: 0 };
length(withZ); // OK, bo withZ przechodzi przez zmienną (nie fresh object literal) type Handler = (e: { type: string }) => void;
const onClick: Handler = (e: { type: string; x: number; y: number }) => {
console.log(e.x, e.y);
};
// Błąd w strict mode: parametr Handler nie gwarantuje pól x/y - Struktura decyduje o kompatybilności, nie nazwa typu ani hierarchia dziedziczenia.
- Excess property checking działa tylko na świeżych literałach obiektowych, nie na zmiennych.
- Type inference redukuje szum, ale w publicznym API funkcji warto adnotować jawnie dla stabilności kontraktu.
- Strukturalna zgodność funkcji wymaga zgodności parametrów i typu zwracanego, nie tylko nazwy sygnatury.
2 · any, unknown i never: trzy skrajne typy
★ egzaminany, unknown i never leżą na przeciwległych krańcach systemu typów: any wyłącza kontrolę typów, unknown wymusza jej odzyskanie przed użyciem, never reprezentuje wartość, która nigdy nie powstanie.
function parseConfig(raw: unknown): string {
if (typeof raw !== 'string') {
throw new TypeError('config must be a string');
}
return raw.toUpperCase(); // OK, raw zawężony do string
}
function unsafeParse(raw: any): string {
return raw.toUpperCase(); // kompiluje się, ale crashuje w runtime dla raw = 42
} function assertUnreachable(x: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
}
function fail(message: string): never {
throw new Error(message);
}
// zmienna typu never akceptuje tylko wartości typu never
function process(input: string | number) {
if (typeof input === 'string') return input.length;
if (typeof input === 'number') return input * 2;
return assertUnreachable(input); // input tutaj ma typ never, bo obie gałęzie wyczerpane
} | Typ | Przyjmuje dowolną wartość? | Operacje bez zawężenia | Typowy use case |
|---|---|---|---|
| any | Tak | Wszystkie, bez kontroli | Migracja z JS, biblioteki bez typów (unikać docelowo) |
| unknown | Tak | Brak, wymaga narrowing | Dane z zewnątrz: JSON.parse, odpowiedzi API, argumenty catch |
| never | Nie (zbiór pusty) | Brak, nic nie da się przypisać do niczego innego niż never | Exhaustiveness checking, funkcje zawsze rzucające |
3 · Zawężanie typów i user-defined type guards
★ egzaminNarrowing to proces, w którym compiler zawęża szeroki typ (unię) do węższego wewnątrz konkretnej gałęzi kodu, na podstawie kontrolnego przepływu: typeof, instanceof, in, porównania równości, czy własnych predykatów.
type Input = string | number | Date;
function format(value: Input): string {
if (typeof value === 'string') return value.trim();
if (typeof value === 'number') return value.toFixed(2);
return value.toISOString(); // zostaje tylko Date
}
interface Dog { bark(): void }
interface Cat { meow(): void }
function speak(animal: Dog | Cat) {
if ('bark' in animal) {
animal.bark(); // zawężone do Dog
} else {
animal.meow(); // zawężone do Cat
}
} interface ApiError {
code: number;
message: string;
}
function isApiError(value: unknown): value is ApiError {
return (
typeof value === 'object' &&
value !== null &&
'code' in value &&
'message' in value
);
}
async function handle(response: unknown) {
if (isApiError(response)) {
console.error(response.code, response.message); // zawężone do ApiError
return;
}
console.log('ok', response);
} function assertIsDefined<T>(value: T): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new Error('Value must be defined');
}
}
function load(id: string | undefined) {
assertIsDefined(id);
return id.toUpperCase(); // id zawężone do string po tej linii
} - 1 Zdefiniuj predykat 'x is Type' zamiast zwracać boolean, gdy wynik ma zawężać typ dla wywołującego.
- 2 Sprawdź każde pole używane dalej, guard musi realnie gwarantować kształt, nie tylko część.
- 3 Preferuj discriminated union nad ręcznym type guard, gdy to możliwe, compiler zawęzi automatycznie po polu-discriminancie.
4 · Discriminated unions i exhaustiveness checking
★ egzaminDiscriminated union (tagged union) to wzorzec, w którym każdy człon unii ma wspólne pole-literal (discriminant), pozwalające compilerowi automatycznie zawężać typ w switch/if bez ręcznych type guardów.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
case 'triangle':
return (shape.base * shape.height) / 2;
default: {
const exhaustiveCheck: never = shape;
throw new Error(`Unhandled shape: ${exhaustiveCheck}`);
}
}
} type Shape2 = Shape | { kind: 'square'; side: number };
function area2(shape: Shape2): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.radius ** 2;
case 'rectangle': return shape.width * shape.height;
case 'triangle': return (shape.base * shape.height) / 2;
default: {
// Error: Type '{ kind: "square"; side: number }' is not assignable to type 'never'
const exhaustiveCheck: never = shape;
throw new Error(String(exhaustiveCheck));
}
}
} - Zawsze dodawaj literal discriminant (np. 'type' albo 'kind') do wariantów unii reprezentujących stan/zdarzenie.
- Kończ switch/if-chain gałęzią z assignmentem do never, to zamienia brak obsługi w błąd kompilacji, nie w bug produkcyjny.
- Redux, state machines i event handling to naturalne miejsca dla discriminated unions.
5 · Generics: constraints, wnioskowanie, const type parameters
★ egzaminGeneryki parametryzują typy i funkcje bez utraty precyzji typowej, w przeciwieństwie do any, który tę precyzję zeruje. Constraints (extends) ograniczają, jakie typy wolno podstawić, keyof i indexed access pozwalają operować na kształcie obiektu w sposób type-safe.
function getProperty<T extends object, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 'u1', name: 'Ada', age: 30 };
const name = getProperty(user, 'name'); // typ: string
// getProperty(user, 'email'); // Error: 'email' nie jest kluczem user function tuple<const T extends readonly unknown[]>(...items: T): T {
return items;
}
const a = tuple('a', 'b', 'c');
// bez 'const': typ [string, string, string] (tuple z widened typami, nie string[])
// z 'const': typ readonly ["a", "b", "c"]
declare function makeConfig<const T>(config: T): T;
const config = makeConfig({ mode: 'production', retries: 3 });
// config.mode: "production" (literal), nie string function merge<T extends object, U extends object>(a: T, b: U): T & U {
return { ...a, ...b };
}
const merged = merge({ id: 1 }, { name: 'Ada' });
// typ merged: { id: number } & { name: string } - extends ogranicza dozwolone typy, nie deklaruje dziedziczenia jak w klasach.
- keyof T + T[K] to standardowy wzorzec bezpiecznego dostępu do właściwości generycznych.
- const type parameter eliminuje potrzebę 'as const' w wywołaniu, gdy funkcja sama chce zachować literal types.
- Generyk bez co najmniej dwóch miejsc użycia tego samego typu w sygnaturze zwykle nie jest potrzebny.
6 · Typy warunkowe, infer i mapped types z key remapping
★ egzaminConditional types (T extends U ? X : Y) i mapped types ({ [K in keyof T]: ... }) to mechanizm meta-programowania na poziomie typów, pozwalający wyprowadzać nowe typy z istniejących zamiast pisać je ręcznie.
type ElementType<T> = T extends (infer U)[] ? U : never;
type A = ElementType<string[]>; // string
type B = ElementType<number>; // never
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type C = MyReturnType<() => Promise<number>>; // Promise<number> type ToArray<T> = T extends unknown ? T[] : never;
type D = ToArray<string | number>; // string[] | number[], nie (string | number)[]
// Zablokowanie dystrybucji: opakowanie T w krotkę wyłącza rozdzielanie po unii
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type E = ToArrayNonDist<string | number>; // (string | number)[] type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Person { name: string; age: number }
type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number }
// Filtrowanie: klucz zmapowany na 'never' znika z wynikowego typu
type OmitByValue<T, V> = {
[K in keyof T as T[K] extends V ? never : K]: T[K];
};
type WithoutStrings = OmitByValue<{ a: string; b: number; c: boolean }, string>;
// { b: number; c: boolean } - Conditional types rozdzielają się po unii tylko gdy sprawdzany typ to 'goły' parametr typu, nie opakowany w krotkę/obiekt.
- infer działa wyłącznie w gałęzi 'extends' warunku, nie poza nim.
- Klauzula 'as' w mapped type remapuje LUB usuwa klucze (zwrot 'never' = usunięcie).
- To fundament większości utility types z lib.es5.d.ts i bibliotek typu Zod czy tRPC.
7 · Wbudowane utility types i operator satisfies
★ egzaminTypeScript dostarcza zestaw generycznych utility types zbudowanych na mapped i conditional types (Partial, Pick, Omit, Record, ReturnType, Awaited), oraz operator 'satisfies' (od 4.9) łączący walidację typu z zachowaniem najwęższego wywnioskowanego typu.
| Utility type | Sygnatura (uproszczona) | Zastosowanie |
|---|---|---|
| Partial<T> | { [K in keyof T]?: T[K] } | Wszystkie pola opcjonalne, np. obiekt patch/update |
| Pick<T, K> | { [P in K]: T[P] } | Wybór podzbioru pól istniejącego typu |
| Omit<T, K> | Pick<T, Exclude<keyof T, K>> | Typ bez wskazanych pól |
| Record<K, V> | { [P in K]: V } | Mapa/słownik o znanych kluczach i jednolitym typie wartości |
| ReturnType<F> | F extends (...a: any[]) => infer R ? R : never | Wyciągnięcie typu zwracanego przez funkcję |
| Awaited<T> | Rekurencyjnie odpakowuje Promise<Promise<...<T>>> | Typ wartości po await, w tym zagnieżdżone Promise/thenable |
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
type UserPatch = Partial<Pick<User, 'name' | 'email'>>;
// { name?: string; email?: string }
type PublicUser = Omit<User, 'email'>;
// { id: string; name: string; role: 'admin' | 'user' }
type UsersByRole = Record<User['role'], User[]>;
// { admin: User[]; user: User[] } async function fetchUser(id: string) {
return { id, name: 'Ada' };
}
type FetchUserReturn = ReturnType<typeof fetchUser>; // Promise<{ id: string; name: string }>
type FetchUserResult = Awaited<FetchUserReturn>; // { id: string; name: string } type Colors = Record<string, string | [number, number, number]>;
// Adnotacja: traci precyzję, 'red' ma typ string | [number,number,number]
const palette1: Colors = { red: [255, 0, 0], green: '#00ff00' };
// palette1.red.toUpperCase(); // Error: 'toUpperCase' nie istnieje na typie [number,number,number], wymaga zawężenia unii
// satisfies: waliduje kształt, ale zachowuje precyzyjny literal type
const palette2 = { red: [255, 0, 0], green: '#00ff00' } satisfies Colors;
// palette2.red ma typ [number, number, number] bez zawężania, dostępne np. palette2.red[0] - Używaj satisfies, gdy chcesz walidację kształtu bez utraty precyzji literalnych typów (np. konfiguracje, palety, routing tables).
- Adnotacja ': Type' jest właściwa, gdy chcesz, by zmienna miała dokładnie ten szerszy typ, np. w publicznym API.
- Awaited jest rekurencyjny, bo Promise<Promise<T>> w JS spłaszcza się przez await do T, typ musi to odzwierciedlać.
8 · Enumy, pułapki, strictNullChecks i template literal types
Enumy to jedyna konstrukcja TS generująca realny kod JS o nietrywialnym runtime behaviour, co czyni je źródłem częstych pułapek. strictNullChecks i template literal types dopełniają obraz nowoczesnego, precyzyjnego typowania.
enum Direction {
Up,
Down,
Left,
Right,
}
function move(dir: Direction) {
console.log(dir);
}
move(0); // OK, Direction.Up, ale brak semantycznej gwarancji
// move(99); // Error TS2345 od TS 5.0: literal poza zakresem enuma jest odrzucany
declare const n: number;
move(n); // OK w compilerze! zmienna typu 'number' nadal omija kontrolę enuma type Direction2 = 'up' | 'down' | 'left' | 'right';
function move2(dir: Direction2) {
console.log(dir);
}
move2('up'); // OK
// move2('diagonal'); // Error: nie jest dozwolonym literałem
// move2(0); // Error: number nie jest Direction2 type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiRoute = `/api/${string}`;
type Endpoint = `${HttpMethod} ${ApiRoute}`;
const e1: Endpoint = 'GET /api/users'; // OK
// const e2: Endpoint = 'FETCH /api/users'; // Error: 'FETCH' nie jest HttpMethod
// Kombinacja z key remapping z sekcji 6: automatyczne generowanie nazw eventów
type EventName<T extends string> = `on${Capitalize<T>}Change`;
type ClickEvent = EventName<'click'>; // "onClickChange" - Preferuj string literal unions nad enum dla nowego kodu, chyba że potrzebujesz realnych wartości runtime i reverse mapping.
- const enum eliminuje runtime overhead dla zwykłych enumów; ambient const enum pozostaje niekompatybilny z --isolatedModules (TS2748), niezależnie od wersji.
- strictNullChecks powinno być włączone w każdym nowym projekcie, wyłączenie to prawie zawsze błąd konfiguracyjny, nie świadomy wybór.
- Template literal types pozwalają walidować formaty stringów (route'y, nazwy CSS custom properties, eventy) na poziomie typów.
9 · Standardowe dekoratory (5.0+) i module resolution bundler
TypeScript 5.0 zastąpił eksperymentalne (legacy) dekoratory implementacją zgodną z finalizowaną propozycją TC39 Stage 3, dostępną bez flagi 'experimentalDecorators'. Ta sama wersja wprowadziła też tryb rozwiązywania modułów 'bundler' dopasowany do realnego zachowania Vite/esbuild/webpack.
function logCall(target: Function, context: ClassMethodDecoratorContext) {
const methodName = String(context.name);
return function (this: unknown, ...args: unknown[]) {
console.log(`Calling ${methodName} with`, args);
return target.apply(this, args);
};
}
class PaymentService {
@logCall
charge(amount: number) {
return amount * 1.0;
}
}
new PaymentService().charge(100); // loguje wywołanie przed uruchomieniem metody {
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"verbatimModuleSyntax": true,
"noEmit": true
}
} - Standard decorators nie wymagają już 'experimentalDecorators: true', są częścią samego języka od 5.0.
- Sygnatura standard decoratora to (value, context), nie (target, propertyKey, descriptor) jak w legacy.
- moduleResolution 'bundler' pasuje do projektów Vite/esbuild/webpack; 'node16'/'nodenext' pasuje do publikowanych paczek Node.js z natywnym ESM.
- Framework-specific decorators (Angular, NestJS) mogą wciąż wymagać legacy trybu, sprawdź dokumentację frameworka przed migracją.
Sprawdź się - testowanie to nauka
24 pytań w losowej kolejności. Twoje wyniki zapisują się lokalnie.