TypeScript jest jednym z najczęściej wybieranych rozszerzeń JavaScriptu w nowoczesnych projektach frontendowych i backendowych.
Pozwala określić, jakiego rodzaju dane mogą być przechowywane w zmiennych, przekazywane do funkcji i zwracane przez API. Dzięki temu wiele błędów można wykryć już podczas pisania kodu, a nie dopiero po uruchomieniu aplikacji.
W tym artykule pokażę Ci praktyczne zastosowanie:
- podstawowych typów,
- typowania funkcji,
- interfejsów,
- typów opcjonalnych,
- typów unii,
- tablic i obiektów,
- odpowiedzi z API,
- bezpiecznej obsługi danych zewnętrznych.
Czym jest TypeScript?
TypeScript jest językiem rozwijanym jako rozszerzenie JavaScriptu. Oznacza to, że poprawny kod JavaScript zazwyczaj może być również używany w pliku TypeScript.
Najważniejszą różnicą jest możliwość stosowania statycznego typowania.
W JavaScript możemy napisać:
let price = 99;
price = "dziewięćdziesiąt dziewięć";JavaScript pozwala zmienić liczbę na tekst. W niewielkim skrypcie nie musi to być problem, ale w większej aplikacji może prowadzić do trudnych do wykrycia błędów.
W TypeScript możemy określić typ zmiennej:
let price: number = 99;
price = "dziewięćdziesiąt dziewięć";Druga operacja zostanie oznaczona jako błąd, ponieważ zmienna price może przechowywać wyłącznie liczby.
Podstawowe typy w TypeScript
Najczęściej używane typy podstawowe to:
string,number,boolean,null,undefined.
Typ string
Typ string służy do przechowywania tekstu.
const courseName: string = "TypeScript od podstaw";Typ number
Typ number obejmuje zarówno liczby całkowite, jak i zmiennoprzecinkowe.
const price: number = 99.99;
const lessonsCount: number = 42;Typ boolean
Typ boolean może przyjąć wartość true albo false.
const isPublished: boolean = true;Automatyczne rozpoznawanie typów
Nie zawsze trzeba podawać typ ręcznie.
TypeScript potrafi rozpoznać go na podstawie przypisanej wartości.
const username = "Rafal";
const age = 27;
const isActive = true;TypeScript rozpozna te zmienne odpowiednio jako:
string
number
booleanDlatego taki zapis:
const username: string = "Rafal";często można uprościć do:
const username = "Rafal";Jawne typowanie jest szczególnie przydatne w przypadku funkcji, obiektów, parametrów i danych pobieranych z API.
Typowanie tablic
Tablicę tekstów możemy zdefiniować w następujący sposób:
const technologies: string[] = [
"TypeScript",
"React",
"Node.js"
];Tablica liczb:
const ratings: number[] = [5, 4, 5, 3];Można również użyć zapisu z typem generycznym:
const technologies: Array<string> = [
"TypeScript",
"React",
"Node.js"
];Oba zapisy są poprawne.
Jeżeli spróbujemy dodać do tablicy tekstowej liczbę, TypeScript zgłosi błąd:
const technologies: string[] = [];
technologies.push("TypeScript");
technologies.push(123);Typowanie funkcji
TypeScript pozwala określić typ parametrów funkcji oraz typ zwracanej wartości.
function calculatePrice(
price: number,
discount: number
): number {
return price - discount;
}Funkcja przyjmuje dwa argumenty typu number i zwraca wartość typu number.
Wywołanie:
const finalPrice = calculatePrice(100, 20);jest poprawne.
Natomiast:
calculatePrice("100", 20);spowoduje błąd typowania.
Funkcja, która niczego nie zwraca
Jeżeli funkcja nie zwraca wartości, możemy użyć typu void.
function displayMessage(message: string): void {
console.log(message);
}Typ void informuje, że funkcja wykonuje operację, ale nie zwraca wyniku, który można przypisać do zmiennej.
Typowanie funkcji strzałkowych
Funkcje strzałkowe typujemy podobnie:
const multiply = (
firstNumber: number,
secondNumber: number
): number => {
return firstNumber * secondNumber;
};Możemy także zapisać funkcję krócej:
const multiply = (
firstNumber: number,
secondNumber: number
): number => firstNumber * secondNumber;Typowanie obiektów
Obiekt można opisać bezpośrednio przy deklaracji zmiennej.
const user: {
id: number;
name: string;
email: string;
active: boolean;
} = {
id: 1,
name: "Anna",
email: "anna@example.com",
active: true
};Takie rozwiązanie działa, ale przy większej liczbie obiektów szybko staje się mało wygodne.
W takim przypadku warto użyć interfejsu.
Interfejsy w TypeScript
Interfejs opisuje strukturę obiektu.
interface User {
id: number;
name: string;
email: string;
active: boolean;
}Następnie możemy wykorzystać ten interfejs podczas tworzenia obiektów:
const user: User = {
id: 1,
name: "Anna",
email: "anna@example.com",
active: true
};Jeżeli zabraknie wymaganej właściwości, TypeScript zgłosi błąd.
const user: User = {
id: 1,
name: "Anna",
active: true
};W tym przykładzie brakuje pola email.
Błąd pojawi się również wtedy, gdy przypiszemy niewłaściwy typ:
const user: User = {
id: "1",
name: "Anna",
email: "anna@example.com",
active: true
};Pole id powinno być liczbą, a nie tekstem.
Właściwości opcjonalne
Nie każde pole obiektu musi być wymagane.
Właściwość opcjonalną oznaczamy znakiem zapytania.
interface User {
id: number;
name: string;
email: string;
phone?: string;
}Teraz obiekt może zawierać numer telefonu, ale nie musi.
const firstUser: User = {
id: 1,
name: "Anna",
email: "anna@example.com"
};const secondUser: User = {
id: 2,
name: "Tomasz",
email: "tomasz@example.com",
phone: "123456789"
};Oba obiekty są zgodne z interfejsem User.
Pola tylko do odczytu
Za pomocą słowa readonly możemy zablokować możliwość zmiany wybranej właściwości.
interface User {
readonly id: number;
name: string;
email: string;
}Po utworzeniu użytkownika nie możemy zmienić jego identyfikatora:
const user: User = {
id: 1,
name: "Anna",
email: "anna@example.com"
};
user.id = 2;TypeScript zgłosi błąd, ponieważ id jest polem tylko do odczytu.
Rozszerzanie interfejsów
Interfejsy mogą dziedziczyć właściwości po innych interfejsach.
interface Person {
name: string;
email: string;
}
interface Admin extends Person {
permissions: string[];
}Obiekt typu Admin musi zawierać zarówno pola z interfejsu Person, jak i własne właściwości.
const admin: Admin = {
name: "Marek",
email: "marek@example.com",
permissions: [
"users:read",
"users:write"
]
};Rozszerzanie interfejsów pomaga unikać powtarzania tych samych definicji.
Typy unii
Typ unii pozwala wskazać kilka dozwolonych typów lub wartości.
let userId: number | string;Zmienna może być liczbą:
userId = 123;albo tekstem:
userId = "123";Możemy także ograniczyć wartość do konkretnego zestawu.
type UserRole = "admin" | "editor" | "viewer";Następnie używamy typu w interfejsie:
interface User {
id: number;
name: string;
role: UserRole;
}Poprawny obiekt:
const user: User = {
id: 1,
name: "Anna",
role: "admin"
};Niepoprawny obiekt:
const user: User = {
id: 1,
name: "Anna",
role: "owner"
};Wartość owner nie znajduje się w zdefiniowanej unii.
Alias typu
Słowo type pozwala tworzyć własne typy.
type ProductId = number;
type ProductStatus = "available" | "unavailable";Możemy ich później używać w innych definicjach:
interface Product {
id: ProductId;
name: string;
status: ProductStatus;
}Alias typu może również opisywać cały obiekt.
type Product = {
id: number;
name: string;
price: number;
};Interface czy type?
Zarówno interface, jak i type mogą opisywać strukturę obiektu.
interface User {
id: number;
name: string;
}type User = {
id: number;
name: string;
};W praktyce często stosuje się:
interfacedo opisywania obiektów i modeli danych,typedo unii, aliasów, typów funkcji i bardziej złożonych kombinacji.
Nie jest to jednak sztywna reguła. Najważniejsza jest spójność w projekcie.
Typowanie zagnieżdżonych obiektów
Dane w aplikacjach często zawierają obiekty zagnieżdżone.
interface Address {
city: string;
street: string;
postalCode: string;
}
interface User {
id: number;
name: string;
address: Address;
}Przykładowy obiekt:
const user: User = {
id: 1,
name: "Anna",
address: {
city: "Wrocław",
street: "Długa 10",
postalCode: "50-001"
}
};Wydzielenie osobnego interfejsu dla adresu poprawia czytelność i pozwala ponownie wykorzystać ten typ.
Tablice obiektów
Jeżeli API zwraca wielu użytkowników, możemy utworzyć tablicę typu User.
interface User {
id: number;
name: string;
email: string;
}const users: User[] = [
{
id: 1,
name: "Anna",
email: "anna@example.com"
},
{
id: 2,
name: "Tomasz",
email: "tomasz@example.com"
}
];Każdy element tablicy musi być zgodny z interfejsem User.
Typowanie odpowiedzi z API
Jednym z najważniejszych zastosowań TypeScriptu jest typowanie danych pobieranych z API.
Załóżmy, że endpoint zwraca użytkownika w następującej postaci:
{
"id": 1,
"name": "Anna",
"email": "anna@example.com",
"active": true
}Najpierw definiujemy interfejs:
interface User {
id: number;
name: string;
email: string;
active: boolean;
}Następnie tworzymy funkcję pobierającą dane:
async function getUser(userId: number): Promise<User> {
const response = await fetch(
`https://api.example.com/users/${userId}`
);
const user: User = await response.json();
return user;
}Typ:
Promise<User>oznacza, że funkcja asynchroniczna zwróci obietnicę zawierającą obiekt zgodny z interfejsem User.
Typowanie listy danych z API
Jeżeli endpoint zwraca tablicę użytkowników, funkcja może wyglądać następująco:
async function getUsers(): Promise<User[]> {
const response = await fetch(
"https://api.example.com/users"
);
const users: User[] = await response.json();
return users;
}Funkcja zwraca tablicę obiektów typu User.
Generyczny typ odpowiedzi API
W wielu projektach odpowiedzi API mają wspólną strukturę.
Przykładowa odpowiedź:
{
"success": true,
"data": {
"id": 1,
"name": "Anna"
},
"message": "User loaded successfully"
}Możemy utworzyć generyczny interfejs:
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}Litera T oznacza typ, który zostanie przekazany podczas użycia interfejsu.
interface User {
id: number;
name: string;
}async function getUser(
userId: number
): Promise<ApiResponse<User>> {
const response = await fetch(
`https://api.example.com/users/${userId}`
);
const result: ApiResponse<User> =
await response.json();
return result;
}Ten sam interfejs możemy wykorzystać dla produktów:
interface Product {
id: number;
name: string;
price: number;
}async function getProducts():
Promise<ApiResponse<Product[]>> {
const response = await fetch(
"https://api.example.com/products"
);
const result: ApiResponse<Product[]> =
await response.json();
return result;
}Dzięki typom generycznym nie trzeba tworzyć osobnego interfejsu odpowiedzi dla każdego endpointu.
Typowanie błędów API
Warto również opisać strukturę błędu.
interface ApiError {
statusCode: number;
message: string;
details?: string[];
}Przykładowa funkcja:
async function getUser(userId: number): Promise<User> {
const response = await fetch(
`https://api.example.com/users/${userId}`
);
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.message);
}
const user: User = await response.json();
return user;
}Dzięki temu wiadomo, jakiej struktury można oczekiwać zarówno w przypadku poprawnej odpowiedzi, jak i błędu.
Czy samo przypisanie typu sprawdza odpowiedź API?
To bardzo ważna kwestia.
Poniższy zapis:
const user: User = await response.json();nie sprawdza rzeczywistej struktury danych podczas działania aplikacji.
TypeScript działa głównie na etapie kompilacji. Jeżeli API zwróci dane o innej strukturze, TypeScript może tego nie wykryć w czasie działania programu.
Przykładowo API może zwrócić:
{
"id": "jeden",
"username": "Anna"
}Mimo że kod oczekuje:
interface User {
id: number;
name: string;
}Dlatego dane z zewnętrznego źródła warto traktować jako unknown i zweryfikować przed użyciem.
Unknown zamiast any
Typ any wyłącza większość mechanizmów kontroli typów.
let data: any;
data.name.toUpperCase();
data.user.profile.address.city;TypeScript nie zgłosi błędu, nawet jeżeli te właściwości nie istnieją.
Bezpieczniejszy jest typ unknown.
let data: unknown;W przypadku unknown musimy najpierw sprawdzić dane przed wykonaniem operacji.
if (typeof data === "string") {
console.log(data.toUpperCase());
}Właśnie dlatego odpowiedzi z API najlepiej początkowo traktować jako dane nieznane.
Type guard do sprawdzania odpowiedzi API
Możemy utworzyć funkcję, która sprawdzi, czy obiekt ma oczekiwaną strukturę.
interface User {
id: number;
name: string;
email: string;
}function isUser(data: unknown): data is User {
if (
typeof data !== "object" ||
data === null
) {
return false;
}
const user = data as Record<string, unknown>;
return (
typeof user.id === "number" &&
typeof user.name === "string" &&
typeof user.email === "string"
);
}Następnie wykorzystujemy funkcję podczas pobierania danych:
async function getUser(userId: number): Promise<User> {
const response = await fetch(
`https://api.example.com/users/${userId}`
);
if (!response.ok) {
throw new Error("Nie udało się pobrać użytkownika");
}
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error("Nieprawidłowa struktura odpowiedzi API");
}
return data;
}Po przejściu walidacji TypeScript wie, że data jest obiektem typu User.
Opcjonalne pola w odpowiedziach API
Niektóre endpointy mogą zwracać różne zestawy danych.
interface User {
id: number;
name: string;
avatarUrl?: string;
}Przed użyciem pola opcjonalnego warto sprawdzić, czy istnieje.
if (user.avatarUrl) {
console.log(user.avatarUrl);
}Można również użyć operatora opcjonalnego łańcuchowania:
console.log(user.avatarUrl?.toUpperCase());Jeżeli avatarUrl nie istnieje, kod nie spowoduje błędu.
Null w odpowiedzi API
Pole może również jawnie przyjmować wartość null.
interface User {
id: number;
name: string;
phone: string | null;
}Przed użyciem numeru telefonu należy wykonać sprawdzenie:
if (user.phone !== null) {
console.log(user.phone);
}Warto odróżniać:
phone?: string;od:
phone: string | null;Pierwszy zapis oznacza, że pole może w ogóle nie występować.
Drugi oznacza, że pole powinno istnieć, ale może zawierać wartość null.
Typowanie parametrów zapytania
Załóżmy, że endpoint obsługuje filtrowanie i paginację.
interface UsersQueryParams {
page?: number;
limit?: number;
active?: boolean;
search?: string;
}Możemy utworzyć funkcję budującą adres zapytania:
function buildUsersUrl(
params: UsersQueryParams
): string {
const query = new URLSearchParams();
if (params.page !== undefined) {
query.set("page", String(params.page));
}
if (params.limit !== undefined) {
query.set("limit", String(params.limit));
}
if (params.active !== undefined) {
query.set("active", String(params.active));
}
if (params.search) {
query.set("search", params.search);
}
return `/api/users?${query.toString()}`;
}Przykładowe użycie:
const url = buildUsersUrl({
page: 1,
limit: 20,
active: true
});TypeScript podpowiada dostępne parametry i pilnuje ich typów.
Typowanie danych wysyłanych do API
Oprócz odpowiedzi warto typować również dane wysyłane do serwera.
interface CreateUserRequest {
name: string;
email: string;
password: string;
}async function createUser(
userData: CreateUserRequest
): Promise<User> {
const response = await fetch(
"https://api.example.com/users",
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(userData)
}
);
if (!response.ok) {
throw new Error("Nie udało się utworzyć użytkownika");
}
const user: User = await response.json();
return user;
}Przykładowe wywołanie:
createUser({
name: "Anna",
email: "anna@example.com",
password: "secure-password"
});Próba pominięcia hasła lub przekazania błędnego typu zostanie oznaczona przez TypeScript.
Oddzielny typ dla tworzenia i odczytu danych
Model zwracany przez API często różni się od danych wysyłanych podczas tworzenia zasobu.
Przy tworzeniu użytkownika nie przesyłamy jego identyfikatora:
interface CreateUserRequest {
name: string;
email: string;
}Natomiast odpowiedź serwera może już zawierać id i datę utworzenia:
interface User {
id: number;
name: string;
email: string;
createdAt: string;
}To dobra praktyka, ponieważ nie każdy model danych powinien być wykorzystywany do wszystkich operacji.
Utility Types
TypeScript udostępnia gotowe typy narzędziowe, które pomagają tworzyć nowe typy na podstawie istniejących.
Partial
Partial powoduje, że wszystkie pola stają się opcjonalne.
interface User {
name: string;
email: string;
active: boolean;
}type UpdateUserRequest = Partial<User>;Teraz możemy aktualizować tylko wybrane pola:
const updateData: UpdateUserRequest = {
active: false
};Pick
Pick pozwala wybrać konkretne pola.
type UserPreview = Pick<User, "name" | "email">;Powstały typ zawiera wyłącznie name i email.
Omit
Omit pozwala usunąć wybrane pola.
type CreateUserRequest = Omit<
User,
"id" | "createdAt"
>;To wygodne rozwiązanie, gdy większość pól jest taka sama, ale niektóre nie powinny być wysyłane do API.
Praktyczny przykład klienta API
Poniżej znajduje się prosty, generyczny klient API.
interface ApiResponse<T> {
data: T;
message?: string;
}async function apiRequest<T>(
url: string,
options?: RequestInit
): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(
`Błąd API: ${response.status}`
);
}
const data: T = await response.json();
return data;
}Pobieranie użytkownika:
interface User {
id: number;
name: string;
email: string;
}const user = await apiRequest<User>(
"https://api.example.com/users/1"
);Pobieranie tablicy produktów:
interface Product {
id: number;
name: string;
price: number;
}const products = await apiRequest<Product[]>(
"https://api.example.com/products"
);Takie rozwiązanie pozwala ponownie wykorzystywać tę samą funkcję dla różnych endpointów.
Należy jednak pamiętać, że samo użycie typu generycznego nie zastępuje walidacji danych podczas działania aplikacji.
Najczęstsze błędy w typowaniu API
Nadużywanie typu any
const data: any = await response.json();Takie rozwiązanie jest szybkie, ale usuwa dużą część korzyści wynikających z TypeScriptu.
Lepiej użyć:
const data: unknown = await response.json();i sprawdzić strukturę danych.
Jeden interfejs do każdej operacji
Ten sam typ nie zawsze powinien służyć do:
- tworzenia obiektu,
- aktualizowania obiektu,
- wyświetlania obiektu,
- obsługi odpowiedzi API.
W praktyce lepiej tworzyć osobne typy, takie jak:
User
CreateUserRequest
UpdateUserRequest
UserListItem
UserDetailsZakładanie, że API zawsze zwraca poprawne dane
TypeScript nie kontroluje serwera.
API może zwrócić:
- brakujące pole,
null,- liczbę zamiast tekstu,
- zmienioną strukturę odpowiedzi,
- komunikat błędu zamiast oczekiwanego obiektu.
Dlatego krytyczne dane powinny być walidowane w czasie działania aplikacji.
Brak obsługi błędów HTTP
Samo wykonanie fetch nie oznacza, że odpowiedź zakończyła się sukcesem.
const response = await fetch(url);Należy sprawdzić:
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}Dobre praktyki TypeScript
Podczas pracy z TypeScriptem warto pamiętać o kilku zasadach.
Po pierwsze, unikaj any, jeżeli możesz zastosować konkretny typ lub unknown.
Po drugie, twórz osobne typy dla danych wysyłanych i odbieranych z API.
Po trzecie, korzystaj z interfejsów i aliasów, aby nie powtarzać definicji obiektów.
Po czwarte, oznaczaj pola opcjonalne tylko wtedy, gdy rzeczywiście mogą nie występować.
Po piąte, nie traktuj typowania jako walidacji danych podczas działania aplikacji.
Po szóste, korzystaj z typów generycznych do tworzenia elementów wielokrotnego użytku.
Po siódme, włącz restrykcyjne ustawienia TypeScriptu, szczególnie:
{
"compilerOptions": {
"strict": true
}
}Tryb strict pomaga wykrywać więcej potencjalnych problemów jeszcze przed uruchomieniem kodu.
Podsumowanie
TypeScript zwiększa bezpieczeństwo kodu i ułatwia rozwijanie większych aplikacji.
Podstawowe typy pozwalają kontrolować wartości przechowywane w zmiennych. Interfejsy opisują strukturę obiektów, a typy unii ograniczają zestaw możliwych wartości.
Szczególnie istotne jest typowanie komunikacji z API. Warto opisywać zarówno dane wysyłane do serwera, jak i otrzymywane odpowiedzi, błędy oraz parametry zapytań.
Należy jednak pamiętać, że TypeScript nie sprawdza automatycznie rzeczywistej struktury danych zwracanych przez zewnętrzne API. W przypadku ważnych danych warto stosować unknown, type guardy albo dodatkowe biblioteki walidacyjne.
Dobrze zaprojektowane typy nie tylko zmniejszają liczbę błędów. Pełnią również funkcję dokumentacji, poprawiają podpowiedzi w edytorze i ułatwiają współpracę nad kodem.
