Jeśli piszesz TypeScript od jakiegoś czasu, ale typy generyczne wciąż sprawiają wrażenie czegoś, co kopiujesz ze Stack Overflow bez pełnego zrozumienia — ten artykuł jest właśnie dla ciebie. Typy generyczne to funkcja, która wynosi TypeScript z „JavaScript z adnotacjami typów" do prawdziwie ekspresywnego systemu typów. Gdy do ciebie dotrą, zobaczysz je wszędzie — i będziesz po nie sięgać naturalnie zamiast domyślnie używać any gdy sprawy stają się trudne. Rozdział Handbook TypeScript o typach generycznych omawia pełną specyfikację; ten artykuł skupia się na wzorcach, których faktycznie użyjesz w prawdziwych bazach kodu.

Problem z any

Typy generyczne istnieją, by rozwiązać konkretny problem: chcesz, żeby funkcja lub struktura danych działała z wieloma typami, ale nie chcesz wyrzucać informacji o typie w tym procesie. Naiwnym rozwiązaniem jest any — i wygląda dobrze dopóki nie zdasz sobie sprawy, z czego zrezygnowałeś:

ts
// With any — TypeScript has no idea what comes out
function identity(arg: any): any {
  return arg;
}

const result = identity('hello');
// result is typed as 'any' — you've lost the string type
// TypeScript won't catch this:
result.toFixed(2); // no error at compile time, crashes at runtime

Informacja o typie wchodzi, ale nie wychodzi. Cokolwiek robisz z result jest całkowicie niezaznaczone. To nie jest baza kodu TypeScript — to JavaScript z dodatkowymi krokami. Typy generyczne rozwiązują to, pozwalając powiedzieć: „Nie znam jeszcze dokładnego typu, ale cokolwiek wchodzi, powinno wyjść z tym samym typem."

ts
// With a generic — T flows through the function
function identity<T>(arg: T): T {
  return arg;
}

const result = identity('hello');
// result is typed as 'string' ✅
result.toFixed(2); // ❌ TypeScript catches this: Property 'toFixed' does not exist on type 'string'

const count = identity(42);
// count is typed as 'number' ✅

Zapis <T> deklaruje parametr typu — pomyśl o nim jak o zmiennej dla typów. TypeScript wnioskuje, czym jest T na podstawie argumentu, który przekazujesz, więc prawie nigdy nie musisz go pisać wprost. identity<string>('hello') działa, ale tak samo działa identity('hello') — TypeScript to rozgryzie.

Funkcje generyczne w praktyce

Funkcja identity jest kanonicznym przykładem dydaktycznym, ale nie czymś, co napisałbyś w produkcji. Oto rodzaje funkcji generycznych, które rzeczywiście pojawiają się w prawdziwych bazach kodu:

ts
// A type-safe array first/last helper
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

function last<T>(arr: T[]): T | undefined {
  return arr[arr.length - 1];
}

const firstUser = first(users);       // UserProfile | undefined
const lastOrder = last(orders);       // Order | undefined
const firstNum  = first([1, 2, 3]);   // number | undefined

// Group array items by a key
function groupBy<T, K extends string | number>(
  items: T[],
  getKey: (item: T) => K
): Record<K, T[]> {
  return items.reduce((acc, item) => {
    const key = getKey(item);
    if (!acc[key]) acc[key] = [];
    acc[key].push(item);
    return acc;
  }, {} as Record<K, T[]>);
}

// TypeScript infers T as Order and K as string
const byStatus = groupBy(orders, order => order.status);
// byStatus: Record<string, Order[]>
// byStatus['pending'] is Order[] ✅

Zwróć uwagę, jak TypeScript automatycznie wnioskuje T i K na podstawie tego, jak wywołujesz funkcję. Uzyskujesz pełne bezpieczeństwo typów dla wartości zwracanej bez pisania ani jednej jawnej adnotacji typu w miejscu wywołania. To jest właśnie wypłata.

Interfejsy i typy generyczne

Tu typy generyczne stają się nieodzowne w codziennej pracy. Każda baza kodu, która komunikuje się z API, potrzebuje kilku generycznych typów opakowujących. Oto te, które będziesz widział (i pisał) nieustannie:

ts
// API response wrapper — used across every endpoint
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: string;
}

// Paginated list response
interface PaginatedResult<T> {
  items: T[];
  page: number;
  pageSize: number;
  totalItems: number;
  totalPages: number;
}

// Using them with concrete types
interface UserProfile {
  id: number;
  name: string;
  email: string;
  avatarUrl: string;
}

interface Order {
  id: number;
  userId: number;
  total: number;
  status: 'pending' | 'shipped' | 'delivered';
}

// The return types are fully typed — no casting needed downstream
async function getUser(id: number): Promise<ApiResponse<UserProfile>> {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

async function listOrders(page: number): Promise<PaginatedResult<Order>> {
  const res = await fetch(`/api/orders?page=${page}`);
  return res.json();
}

const response = await getUser(1);
response.data.email;        // ✅ string
response.data.avatarUrl;    // ✅ string

const result = await listOrders(1);
result.items[0].status;     // ✅ 'pending' | 'shipped' | 'delivered'
result.totalPages;          // ✅ number
Gdy budujesz te typy na podstawie prawdziwych ładunków API, narzędzie JSON to TypeScript może automatycznie wygenerować typy wewnętrzne (UserProfile, Order) z przykładowej odpowiedzi. Następnie wstawiasz je do swoich generycznych opakowania.

Ograniczenia typów generycznych

Czasem chcesz akceptować wiele typów, ale nie absolutnie każdy typ. Ograniczenia generyczne pozwalają określić, co parametr typu musi mieć. Składnia to T extends SomeType — co oznacza „T musi być przypisywalne do SomeType":

ts
// Without constraint — TypeScript doesn't know T has an id property
function findById<T>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id); // ❌ Property 'id' does not exist on type 'T'
}

// With constraint — T must have at least an id: number field
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id); // ✅
}

// Works with any type that has id: number
const user  = findById(users, 42);   // UserProfile | undefined
const order = findById(orders, 101); // Order | undefined

// Another common constraint — T must be an object (excludes primitives)
function mergeDefaults<T extends object>(partial: Partial<T>, defaults: T): T {
  return { ...defaults, ...partial };
}

// T must have a name: string and email: string
function formatContact<T extends { name: string; email: string }>(contact: T): string {
  return `${contact.name} <${contact.email}>`;
}

// Works on any object with those two fields — UserProfile, Employee, whatever
formatContact({ name: 'Alice', email: '[email protected]', role: 'admin' }); // ✅

Ograniczenie nie blokuje T do dokładnie { id: number } — oznacza, że T musi mieć przynajmniej taki kształt. Więc przekazanie pełnego UserProfile z dziesięcioma polami jest w porządku. To jest typowanie strukturalne w działaniu, które jest jedną z najpotężniejszych i najlepiej udokumentowanych cech TypeScript.

Ograniczenie keyof

Jednym z najbardziej przydatnych wzorców generycznych w standardowej bibliotece TypeScript i rzeczywistych bazach kodu jest łączenie typów generycznych z keyof. Pozwala pisać funkcje, które akceptują nazwę właściwości typu obiektu i gwarantują, że typ zwracany odpowiada tej właściwości.

ts
// keyof T is the union of all keys of T as string literals
// K extends keyof T means K must be one of those keys
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user: UserProfile = {
  id: 1,
  name: 'Alice',
  email: '[email protected]',
  avatarUrl: 'https://example.com/avatar.png'
};

const name  = getProperty(user, 'name');  // string ✅
const id    = getProperty(user, 'id');    // number ✅
const email = getProperty(user, 'email'); // string ✅

getProperty(user, 'password'); // ❌ Argument of type '"password"' is not assignable to
                               //    parameter of type 'keyof UserProfile'

// Real use case: a generic sort function
function sortBy<T>(items: T[], key: keyof T): T[] {
  return [...items].sort((a, b) => {
    const av = a[key];
    const bv = b[key];
    return av < bv ? -1 : av > bv ? 1 : 0;
  });
}

const byName = sortBy(users, 'name');   // ✅ sorted by name
const byId   = sortBy(users, 'id');     // ✅ sorted by id
sortBy(users, 'nonexistent');           // ❌ caught at compile time

Domyślne parametry typu

Podobnie jak parametry funkcji mogą mieć wartości domyślne, parametry typu też mogą. Jest to przydatne gdy masz typ generyczny, który jest prawie zawsze używany z konkretnym typem, ale chcesz zachować elastyczność dla przypadków, gdy tak nie jest.

ts
// Default type parameter — T defaults to string if not specified
interface Cache<T = string> {
  get(key: string): T | undefined;
  set(key: string, value: T, ttlMs?: number): void;
  delete(key: string): void;
  clear(): void;
}

// Without specifying T — uses the default (string)
declare const stringCache: Cache;
const val = stringCache.get('theme'); // string | undefined

// Specifying a different T
declare const userCache: Cache<UserProfile>;
const user2 = userCache.get('user:42'); // UserProfile | undefined

// Another useful example: an event emitter with a typed payload
interface TypedEvent<TPayload = void> {
  subscribe(handler: (payload: TPayload) => void): () => void;
  emit(payload: TPayload): void;
}

// Events with no payload — default void keeps the API clean
const appReady: TypedEvent = { /* ... */ };
appReady.emit(); // no argument needed

// Events with a payload
const userLoggedIn: TypedEvent<{ userId: number; timestamp: Date }> = { /* ... */ };
userLoggedIn.emit({ userId: 42, timestamp: new Date() });

Wbudowane typy narzędziowe generyczne

TypeScript dostarcza zestaw wbudowanych typów narzędziowych które są wszystkie zaimplementowane przy użyciu typów generycznych, których się nauczyłeś. Rozumienie, jak ich używać jest jedną z najbardziej praktycznych umiejętności TypeScript, jakie możesz posiadać. Oto podstawowy zestaw z prawdziwymi przykładami pokazującymi wynikowy typ:

ts
interface UserProfile {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'viewer';
  passwordHash: string;
  createdAt: Date;
}

// Partial<T> — all fields become optional (great for PATCH payloads)
type UpdateUserPayload = Partial<UserProfile>;
// { id?: number; name?: string; email?: string; role?: ...; ... }

// Required<T> — all fields become required (reverse of Partial)
interface DraftConfig {
  apiUrl?: string;
  timeout?: number;
  maxRetries?: number;
}
type ResolvedConfig = Required<DraftConfig>;
// { apiUrl: string; timeout: number; maxRetries: number }

// Pick<T, K> — keep only the named fields
type UserSummary = Pick<UserProfile, 'id' | 'name' | 'email'>;
// { id: number; name: string; email: string }
// Use in list views — only ship what the UI needs

// Omit<T, K> — exclude the named fields
type PublicUserProfile = Omit<UserProfile, 'passwordHash'>;
// { id: number; name: string; email: string; role: ...; createdAt: Date }
// Safe to include in API responses

// Record<K, V> — typed dictionary / map
type PermissionMap = Record<UserProfile['role'], string[]>;
// { admin: string[]; user: string[]; viewer: string[] }

const permissions: PermissionMap = {
  admin:  ['read', 'write', 'delete', 'admin'],
  user:   ['read', 'write'],
  viewer: ['read']
};

// ReturnType<T> — infer what a function returns (keeps types in sync automatically)
function buildUserSession(user: UserProfile) {
  return {
    token: crypto.randomUUID(),
    userId: user.id,
    role: user.role,
    expiresAt: new Date(Date.now() + 3_600_000)
  };
}

type UserSession = ReturnType<typeof buildUserSession>;
// { token: string; userId: number; role: 'admin' | 'user' | 'viewer'; expiresAt: Date }
// Change buildUserSession and UserSession updates automatically ✅

Jeśli konwertujesz istniejący JavaScript do TypeScript i budujesz te typy od zera, narzędzie JS to TypeScript może pomóc uzyskać wstępne typy, dzięki czemu możesz zacząć nakładać na nie typy narzędziowe.

Realistyczny przykład końca do końca: Typowany klient API

Oto wzorzec, po który sięgam za każdym razem, gdy buduję typowaną warstwę API. Łączy funkcje generyczne, interfejsy generyczne i typy narzędziowe w coś, co daje pełne bezpieczeństwo typów od końca do końca, od wywołania fetch aż do komponentu konsumującego dane:

ts
// The response envelope — wraps every API response
interface ApiResponse<T> {
  data: T;
  meta: {
    requestId: string;
    duration: number;
  };
}

// Error envelope — what the API sends on failure
interface ApiError {
  code: string;
  message: string;
  details?: Record<string, string[]>;
}

// The generic fetch wrapper
async function fetchApi<T>(
  url: string,
  options?: RequestInit
): Promise<ApiResponse<T>> {
  const res = await fetch(url, {
    headers: { 'Content-Type': 'application/json', ...options?.headers },
    ...options
  });

  if (!res.ok) {
    const err: ApiError = await res.json();
    throw new Error(`[${err.code}] ${err.message}`);
  }

  return res.json();
}

// Typed endpoint functions — T is set at each call site
async function getUser(id: number): Promise<UserProfile> {
  const { data } = await fetchApi<UserProfile>(`/api/users/${id}`);
  return data;
}

async function listOrders(userId: number, page = 1): Promise<PaginatedResult<Order>> {
  const { data } = await fetchApi<PaginatedResult<Order>>(
    `/api/users/${userId}/orders?page=${page}`
  );
  return data;
}

async function createOrder(
  payload: Pick<Order, 'userId' | 'total'>
): Promise<Order> {
  const { data } = await fetchApi<Order>('/api/orders', {
    method: 'POST',
    body: JSON.stringify(payload)
  });
  return data;
}

// Usage — fully typed, no casting anywhere
const user = await getUser(42);
console.log(user.email);          // string ✅

const orders = await listOrders(42);
orders.items[0].status;           // 'pending' | 'shipped' | 'delivered' ✅
orders.totalPages;                // number ✅

const newOrder = await createOrder({ userId: 42, total: 99.99 });
newOrder.id;                      // number ✅
Możesz eksperymentować z tymi wzorcami bezpośrednio w TypeScript Playground — wklej kod, najedź kursorem na zmienne, żeby zobaczyć wnioskowane typy, i próbuj psuć rzeczy, żeby zobaczyć co kompilator wyłapuje. To najszybsza pętla informacji zwrotnej przy uczeniu się typów generycznych.

Częste błędy

Kilka wzorców do uniknięcia, gdy już czujesz się komfortowo z typami generycznymi:

  • Używanie any wewnątrz generycznego. Jeśli piszesz function wrap<T>(val: T): any, zniweczyłeś cel. Cały sens polega na tym, że typ przepływa przez — używanie any jako typ zwracany lub wewnątrz implementacji oznacza, że TypeScript nie może go już śledzić.
  • Nadmierne ograniczanie za pomocą konkretnych typów. Pisanie function process<T extends UserProfile> gdy potrzebujesz tylko T extends { id: number } jest zbyt restrykcyjne. Używaj minimalnego ograniczenia, które sprawia, że twoja implementacja działa — w ten sposób funkcja pozostaje wielokrotnego użytku dla różnych typów, które mają ten sam kształt.
  • Sięganie po typy generyczne gdy unia wystarczy. Jeśli funkcja przyjmuje albo string albo number, a logika jest różna dla każdego, function f(arg: string | number) z typową ochroną wewnątrz jest czystsze niż generyczne. Typy generyczne błyszczą gdy ta sama logika dotyczy wszystkich wariantów typów.
  • Zbyt wiele parametrów typu. Jeśli piszesz <T, U, V, W>, zrób krok wstecz. To zwykle znak, że funkcja robi zbyt wiele, lub typy mogłyby być wyrażone jako pojedynczy interfejs. Samo źródło kompilatora TypeScript jest dobrą referencją — nawet złożone narzędzia rzadko potrzebują więcej niż 2–3 parametry typu.

Podsumowanie

Typy generyczne to punkt, w którym TypeScript przestaje być „opatrzonym adnotacjami JavaScript" i zaczyna być prawdziwie potężnym systemem typów. Podstawowa idea jest prosta: uchwyć typ jako parametr tak, żeby przepływał przez twoją funkcję lub interfejs nie tracąc informacji. Od tego momentu, ograniczenia pozwalają zawęzić to, co jest dopuszczalne, keyof daje bezpieczny dostęp do właściwości, a wbudowane typy narzędziowe (Partial, Pick, Omit, Record) obsługują wzorce, które w innym razie pisałbyś ręcznie. Najlepszym następnym krokiem jest otwarcie prawdziwego projektu, znalezienie funkcji, w której użyłeś any, bo nie znałeś innego sposobu, i zastąpienie jej generycznym. Rozdział TypeScript Handbook o typach generycznych i dokumentacja typów narzędziowych to dwie strony warte dodania do zakładek. A jeśli budujesz interfejsy z prawdziwych ładunków API, JSON to TypeScript może wygenerować typy konkretne — a następnie wystarczy owinąć je w generyczne typy narzędziowe, takie jak ApiResponse<T> i PaginatedResult<T>, aby uzyskać pełne bezpieczeństwo typów od końca do końca.