W tym artykule chciałbym opisać podejście rozwiązujące niektóre typowe problemy związane z pobieraniem danych w Angularze. Rozważmy dwa scenariusze, w których odpowiedź API może stanowić wyzwanie dla aplikacji Angular:
- Brakujące pola obowiązkowe lub niewłaściwy typ danych:
Nawet jeśli dokumentacja API określa pewne pola jako obowiązkowe, API może czasami zwracać odpowiedzi, w których te pola są brakujące lub mają wartość undefined. Na przykład, jeśli API ma zwracać dane użytkownika z polem email jako obowiązkowym, a odpowiedź API to { “name”: “John Doe” } bez pola email, Twoja aplikacja Angular może napotkać błędy przy próbie dostępu do tego brakującego pola. Logika aplikacji musi radzić sobie z takimi przypadkami w sposób elegancki, na przykład poprzez sprawdzenie istnienia pola przed jego użyciem.
- Złożone lub nieoptymalne modele danych:
Model danych zwracany przez API może być zbyt złożony lub zawierać wiele pól oraz zagnieżdżonych obiektów, które nie są potrzebne Twojej aplikacji. Może to skomplikować pracę z danymi i wymagać dodatkowego kodu do mapowania ich na prostszą strukturę. Na przykład, jeśli odpowiedź API jest głęboko zagnieżdżona, jak { “user”: { “details”: { “profile”: { “contacts”: { “email”: “john.doe@example.com” } } } } }, a Twoja aplikacja Angular potrzebuje tylko pola email, będziesz musiał przejść przez wiele poziomów zagnieżdżenia, aby je wyodrębnić. Dodaje to dodatkowy kod i zwiększa ryzyko błędów, jeśli struktura API się zmieni.
W obu przypadkach kluczowe jest zaimplementowanie solidnej logiki obsługi i transformacji danych w aplikacji, aby zapewnić jej płynne działanie, nawet gdy odpowiedź API odbiega od oczekiwań lub jest bardziej złożona niż to konieczne. Pozwól, że przedstawię podejście, które minimalizuje te ryzyka i niedogodności oraz daje Ci pełną kontrolę nad typem odpowiedzi z API. Dodatkowo pozwoli Ci ono bezpiecznie przekształcić ten typ w zoptymalizowany interfejs. Co więcej, zamierzam przedstawić bibliotekę zod.js i pokazać Ci rzeczywisty przykład parsowania schematu i wywnioskowywania typów.
Chciałbym zaprezentować implementację serwisu, który odpowiada za pobieranie danych z API, parsowanie odpowiedzi zgodnie z określonym schematem i mapowanie jej na zoptymalizowany interfejs, który można łatwo wykorzystać w szablonie lub logice biznesowej Twojej aplikacji. Dodatkowo, potencjalne błędy będą wychwytywane i obsługiwane tak wcześnie, jak to możliwe.
Serwis do pobierania danych
Poniżej znajdziesz przykład serwisu, który odpowiada za pobieranie i zwracanie odpowiedzi w oczekiwanym kształcie. Celowo w nazwie użyto frazy „data”, aby wskazać na jego pojedynczą odpowiedzialność, jaką jest pobieranie danych.
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { catchError, map, Observable, of } from 'rxjs';
import { User } from '../users.model';
import { parseDTO } from './users.dto';
import { fromDTO } from './users.mapper';
@Injectable({
providedIn: 'root',
})
export class UsersDataService {
httpClient = inject(HttpClient);
fetchUsers(): Observable<User[]> {
const url = 'https://dummyjson.com/users';
return this.httpClient.get(url).pipe(
map((response) => {
const dto = parseDTO(response);
if (dto.success) {
return fromDTO(dto.data);
} else {
console.error(dto.error);
return [];
}
}),
catchError((error) => {
console.error(error);
return of([]);
})
);
}
}
Zaraz po nadejściu odpowiedzi z API, następuje próba jej parsowania. W przypadku sukcesu, co oznacza, że wszystkie wymagane właściwości są obecne i mają oczekiwane typy, może zostać przemapowana na zoptymalizowany model aplikacji. Sparsowana odpowiedź nazywana jest „DTO”, co oznacza „data transfer object” (obiekt transferu danych) – jest to nasz kontrakt z backendem.
W przypadku błędu, który wystąpi, gdy odpowiedź nie spełnia wszystkich obowiązkowych warunków (jest po prostu inna, niż się spodziewasz), możesz obsłużyć go według własnych potrzeb. W powyższym przykładzie serwis zwraca pustą tablicę, ale może zwrócić dowolną wartość, taką jak undefined lub komunikat o błędzie. Może to być cokolwiek co jest potrzebne do prawidłowego obsłużenia błędu.
DTO – Obiekt transferu danych
Teraz przyjrzyjmy się plikowi „users.dto.ts”, który zawiera kluczową część tej architektury, czyli schemat, funkcje parsujące i wywnioskowany typ.
import { z } from 'zod';
const usersSchema = z.object({
users: z.array(
z.object({
id: z.number(),
firstName: z.string(),
lastName: z.string(),
age: z.number().optional(),
gender: z.string(),
address: z.object({
address: z.string(),
city: z.string(),
state: z.string(),
}),
company: z.object({
address: z.object({
address: z.string(),
city: z.string().optional(),
state: z.string(),
}),
name: z.string(),
}),
})
),
});
export type UsersDto = z.infer<typeof usersSchema>;
export function parseDTO(source: unknown) {
return usersSchema.safeParse(source);
}
Przede wszystkim warto wspomnieć o bibliotece zod.js, która została tutaj użyta. Czym jest zod.js? Według dokumentacji jest to biblioteka deklaracji i walidacji schematów działająca z TypeScript. Krótko mówiąc, oferuje programiście trzy rewolucyjne możliwości:
- Możliwość definiowania schematów. Obsługuje typy podstawowe, złożone zagnieżdżone obiekty, opcjonalne/nullowalne wartości, unie dyskryminacyjne i wiele innych. Umożliwia opisanie każdego możliwego obiektu JSON – w naszym przypadku odpowiedzi z API.
- Wnioskowanie typów. Może wyodrębnić typ TypeScript z danego schematu, więc nie ma potrzeby ręcznego tworzenia typu DTO. Zod.js zrobi to za nas.
- Parsowanie schematów. Zod zweryfikuje dany obiekt pod kątem wszystkich warunków schematu i przemapuje go na typ DTO w przypadku sukcesu; w przeciwnym razie zwraca czytelny komunikat o błędzie z detalami dotyczącymi problemu.
W podanym przykładzie „userSchema” odzwierciedla odpowiedź z przykładowego API „https://dummyjson.com/users”, które, jak zapewne zauważyłeś, zawiera wiele właściwości, z których nie wszystkie są potrzebne w naszej aplikacji. Dlatego w schemacie uwzględnione są tylko te niezbędne. Użyte są wszystkie istotne typy, takie jak string, object i array. Jeśli musisz opisać bardziej złożone schematy, powinieneś zapoznać się z dokumentacją zod.js.
Następnie używany jest typ infer z biblioteki zod.js, aby stworzyć nowy typ na podstawie schematu. Aby zobaczyć moc tej funkcji, najeżdżając na typ UserDto, zauważysz, że poprawnie wnioskowano wszystkie właściwości, nawet te opcjonalne. Moim zdaniem To prawdziwy “game changer”.

Warto wspomnieć, że wymagany jest “strict mode” w tsconfig.json. Bez niego TypeScript założyłby że każda właściwość jest opcjonalna. Zdecydowanie zachęcam do domyślnego włączania tej opcji w Twoim projekcie, co pozwala wykorzystać pełną moc TypeScript.

Mapowanie
Na koniec zobaczmy ostatnią, ale równie istotną część danej architektury, czyli user.mapper.ts, który odpowiada za mapowanie z DTO na optymalny interfejs aplikacji.
Przez “optymalny” rozumiem obiekt, który można łatwo wyświetlić lub przetworzyć w logice biznesowej aplikacji. Powinien być dostosowany do potrzeb, a zarazem prosty. W związku z tym powinien być jak najbardziej płaski oraz zawierać jednoznaczne nazwy właściwości z odpowiednimi typami. W tym przykładzie interfejs User wygląda następująco:
export interface User {
id: number;
fullName: string;
age?: number;
gender: string;
company: {
name: string;
address: string;
};
address: string;
}
Mapper to czysta funkcja, która przyjmuje UsersDto i zwraca tablicę obiektów User. Ponieważ odpowiedź została pomyślnie sparsowana, nie ma potrzeby sprawdzania obecności, ani typu poszczególnych właściwości. Zod.js gwarantuje w 100%, że wszystko jest zgodne z definicją schematu.
import { join } from 'lodash';
import { User } from '../users.model';
import { UsersDto } from './users.dto';
export function fromDTO(dto: UsersDto): User[] {
return dto.users.map((user) => {
const companyAddress = user.company.address;
const userAddress = user.address;
const fullName = `${user.firstName} ${user.lastName}`;
return {
id: user.id,
fullName,
age: user.age,
gender: user.gender,
company: {
name: user.company.name,
address: join(
[companyAddress.address, companyAddress.city, companyAddress.state],
', '
),
},
address: join(
[userAddress.address, userAddress.city, userAddress.state],
', '
),
};
});
}
Teraz lista użytkowników może być używana w komponencie. Odpowiedź została prawidłowo sparsowana i przemalowana na optymalny model.
Chwila, czy to naprawdę jest potrzebne?
Zakładam, że zastanawiasz się, dlaczego po prostu nie dodałem typu generycznego do metody get z serwisu HttpClient. Na pierwszy rzut oka mogłoby to zwrócić odpowiedź w oczekiwanym typie, więc całe to zamieszanie z parsowaniem schematu wydaje się niepotrzebne – cóż, nic bardziej mylnego. Nie masz żadnej gwarancji, że rzeczywisty typ będzie zgodny z Twoim typem. Pozwól, że udowodnię to.
Zmodyfikujemy nasz serwis, aby używał typu generycznego.
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { catchError, tap, Observable, of } from 'rxjs';
import { User } from '../users.model';
@Injectable({
providedIn: 'root',
})
export class UsersDataService {
httpClient = inject(HttpClient);
fetchUsers(): Observable<User[]> {
const url = 'https://dummyjson.com/users';
return this.httpClient.get<User[]>(url).pipe(
tap((users) => console.log(users)),
catchError((error) => {
console.error(error);
return of([]);
})
);
}
}
Chociaż wiemy, że API zwraca inny model, nie wyświetla się żaden błąd. Angular zakłada, że odpowiedź jest tablicą obiektów User. Co zaskakujące, gdy uruchomisz kod, możesz zobaczyć zupełnie inny typ od oczekiwanego.

Oczywiście spowoduje to jakiś błąd lub problem w bardzo nieoczekiwanej części aplikacji. Dlatego odpowiedź powinna być sparsowana, a potencjalne błędy powinny być obsługiwane tak wcześnie, jak to możliwe. Dzięki architekturze przedstawionej w tym artykule, odbywa się to tuż po otrzymaniu danych z backendu.
Podsumowanie
Implementując te trzy składowe (DTO, serwis i mapper), możesz łatwo używać serwisu danych w swoim komponencie, aby pobierać dane i otrzymać je w oczekiwanym kształcie. Jak mogłeś zauważyć, to podejście rozwiązuje wszystkie problemy związane z odpowiedzią API, które mogą wystąpić, i wzniesie Twoją architekturę na wyższy poziom dzięki następującym usprawnieniom:
- Jest to zabezpieczenie w przypadku nieoczekiwanej desynchronizacji kontraktu między API a aplikacją.
- Odpowiedź jest walidowana, więc masz pewność, że wszystkie wymagane właściwości są obecne i mają odpowiednie typy.
- Model jest zoptymalizowany pod kątem potrzeb aplikacji, wszystkie nazwy są zrozumiałe, a obiekt może być łatwo wyświetlany lub przetwarzany.
Repository: https://github.com/maciejkoch/angular-data-service-zod