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ś:
// 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 runtimeInformacja 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."
// 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:
// 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:
// 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; // ✅ numberUserProfile, 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":
// 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.
// 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 timeDomyś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.
// 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:
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:
// 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 ✅Częste błędy
Kilka wzorców do uniknięcia, gdy już czujesz się komfortowo z typami generycznymi:
- Używanie
anywewnątrz generycznego. Jeśli piszeszfunction wrap<T>(val: T): any, zniweczyłeś cel. Cały sens polega na tym, że typ przepływa przez — używanieanyjako 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 tylkoT 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
stringalbonumber, 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.