Zastąpienie `any` w TypeScript: sześć bezpiecznych wzorów typowych dla często występujących przypadków
Przyswoj sobie praktyczne, bezpieczne pod względem typów alternatywy dla `any` w TypeScriptie — w tym typ unknown, generyki, zjednoczenia dyskryminowane oraz sprawdzania wyczerpujące — służące do obsługi nieprzewidywalnych danych.
Źródła: Stop Using ‘any’ in TypeScript — Here’s the Right Fix Every Time
Niewielka zmiana w dyscyplinie, która chroni przed niespodziankami w produkcji
Każdy programista używający TypeScripta spotkał się z tym. Jest późno w piątkowy popołudniu, dziwny błąd kompilatora blokuje twoją prośbę o połączenie kodu, a po dwudziestu minutach walki z generykami w nawiasach i typami unii jedyne, czego pragniesz, to wydać kod i zamknąć laptopa.
Dlatego sięgasz po te trzy litery, które rozwiązują wszystko i jednocześnie nic:
const payload: any = response.data;
Czerwona linia podkreślająca znika. Proces budowania staje się zielony. Wszystko wygląda dobrze.
Ale to nie jest dobre rozwiązanie. Użycie any wcale nie rozwiązuje problemu z pisaniem kodu – mówi to TypeScriptowi, by przestał zwracać na to uwagę. Wyłączasz w ten sposób właśnie tę sieć bezpieczeństwa, która sprawiła, że wybrałeś TypeScripta od początku. Za każdym razem, gdy pojawia się any, powstaje luka w twojej ochronie typów, a ta luka cicho rozprzestrzenia się na każdą funkcję i zmienną, która z nią wchodzi w interakcję.
Dobrą wiadomością jest to, że prawie nigdy tak naprawdę go nie potrzebujesz. Poniżej znajduje się wyjaśnienie, dlaczego any wyrządza tyle szkody, a następnie sprawdzone alternatywy dla sytuacji, w których programiści najczęściej się do niego uciekają.
Co tak naprawdę oznacza any (i dlaczego przynosi negatywne skutki)
Wielu programistów zakłada, że any oznacza po prostu „typ tej wartości nie jest jeszcze znany”.
To, co faktycznie przekazuje kompilatorowi, to: „Przestań sprawdzać tę zmienną i zaufaj, że wszystko, co robię z nią później, jest poprawne.”
let user: any = { name: "Alice" };
// TypeScript allows all of these without a single warning:
user.authenticate(); // Runtime crash: user.authenticate is not a function
user.address.postalCode; // Runtime crash: Cannot read properties of undefined
user(); // Runtime crash: user is not a function
Sytuację pogarsza to, że typem może być any. Gdy typ zwracany przez funkcję to any, każda zmienna przechowująca tę wartość również staje się typu any. Przekazanie tej wartości do innej funkcji powoduje dalsze utrzymywanie się braku bezpieczeństwa. Wkrótce duże części twojego kodu stają się w rzeczywistości zwykłym JavaScriptem z rozszerzeniem .ts.
Poniższe sekcje przedstawiają właściwe alternatywy dla każdego powszechnego przypadku, w którym typ any ma tendencję do pojawiania się.
Rozwiązanie 1: Gdy typ rzeczywiście nie jest jeszcze znany (unknown)
Sytuacja: Pobierasz dane z zewnętrznego API, parsujesz ciąg JSON wyjęty ze lokalnego przechowywania lub obsługujesz surowy payload z webhooka. W takich przypadkach nie ma sposobu, aby dowiedzieć się o dokładnej strukturze danych przed uruchomieniem programu.
Problematyczne podejście:
function parseApiResponse(jsonString: string): any {
return JSON.parse(jsonString);
}
const data = parseApiResponse('{"id": 101}');
console.log(data.profile.avatar); // Compiles fine, crashes immediately if profile is missing
Prawidłowe rozwiązanie: Skorzystaj z typu unknown w połączeniu z type guardem.
unknown funkcjonuje jak bezpieczniejsza wersja typu any. Mówi kompilatorowi: „Typ tej wartości nie został jeszcze określony, ale nie wolno Ci uzyskiwać dostępu do jej właściwości, wywoływać metod ani w żaden inny sposób na niej operować, dopóki nie udowodnisz, czym ona właściwie jest.”
interface UserProfile {
id: number;
name: string;
}
// Custom type guard
function isUserProfile(value: unknown): value is UserProfile {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof (value as Record<string, unknown>).id === "number" &&
"name" in value &&
typeof (value as Record<string, unknown>).name === "string"
);
}
function parseApiResponse(jsonString: string): unknown {
return JSON.parse(jsonString);
}
const rawData = parseApiResponse('{"id": 101, "name": "Sarah"}');
// rawData.name; // Error: Object is of type 'unknown'.
if (isUserProfile(rawData)) {
// Inside this block, TypeScript knows rawData is definitely UserProfile
console.log(rawData.name); // Fully typed, autocompleted, and safe!
}
Jeśli pisanie własnych mechanizmów ochrony dla złożonych danych wydaje się zbyt pracochłonne, możesz użyć biblioteki do walidacji w czasie wykonywania, takiej jak Zod lub Valibot. Te biblioteki walidują nieznane dane i w jednym kroku ustalają odpowiedni typ TypeScript dla nich.
Rozwiązanie 2: Gdy jedna funkcja musi obsługiwać kilka typów (generyki)
Sytuacja: Tworzysz narzędzie wielokrotnego użycia – np. otoczkę do cache’owania, pomocnik do obsługi tablic lub metodę w klientze API – które ma działać z różnymi strukturami danych.
Problematyczne podejście:
function getFirstItem(items: any[]): any {
return items[0];
}
const numbers = [1, 2, 3];
const firstNum = getFirstItem(numbers); // firstNum is typed as 'any'
firstNum.toUpperCase(); // Compiles! Crashes at runtime because it's a number.
Prawidłowe rozwiązanie: Skorzystaj z generyków (składnia <T>).
Generiki funkcjonują jak miejsca zastępcze dla typów, a nie wartości. W odróżnieniu od any, który usuwa informacje o typie, generik pamięta typ podany przez wywołującego i przenosi go aż do wyniku zwracanego przez funkcję.
function getFirstItem<T>(items: T[]): T | undefined {
return items[0];
}
const numbers = [1, 2, 3];
const firstNum = getFirstItem(numbers);
// TypeScript automatically infers firstNum as: number | undefined
const names = ["Alice", "Bob"];
const firstName = getFirstItem(names);
// TypeScript automatically infers firstName as: string | undefined
Można również ograniczyć możliwe wartości generika za pomocą extends, aby upewnić się, że typ zawiera określone wymagane właściwości:
interface HasId {
id: string | number;
}
function printId<T extends HasId>(entity: T): void {
console.log(entity.id);
}
Rozwiązanie 3: Obsługa błędów w bloku try / catch
Sytuacja: Piszesz logikę w bloku catch, aby poradzić sobie z błędem.
Problematyczne podejście:
try {
fetchUserData();
} catch (err: any) {
// Risky: In JavaScript, anything can be thrown (strings, numbers, null)
console.error(err.message.toUpperCase());
}
Ryzyko polega na tym, że JavaScript pozwala rzucać dosłownie wszystkim — łańcuchami, liczbami, null, zwykłymi obiektami, a nie tylko instancjami Error. Jeśli jakaś biblioteka lub nieoczekiwany błąd rzuca zwykły łańcuch, na przykład throw „Network timeout”, to err.message będzie wartością undefined, a wywołanie metody takiej jak .toUpperCase() na tym obiekcie może spowodować nowy, nierozwiązany błąd bezpośrednio w kodzie przeznaczonym do obsługi błędów.
Prawidłowe rozwiązanie: Zadeklaruj złapany błąd jako unknown i zawęż jego typ za pomocą instanceof Error.
try {
fetchUserData();
} catch (err: unknown) {
if (err instanceof Error) {
// TypeScript safely knows 'err' has a .message property
console.error(`Error: ${err.message}`);
} else {
// Fallback for non-standard thrown objects
console.error("An unexpected error occurred", String(err));
}
}
Rozwiązanie 4: Obiekty dynamiczne i wyszukiwanie według kluczy
Sytuacja: Potrzebujesz obiektu, który zachowuje się jak mapa lub słownik, z kluczami znanymi tylko w trakcie działania programu.
Problematyczne podejście:
const userRolePermissions: any = {};
userRolePermissions["admin"] = ["read", "write", "delete"];
Prawidłowe rozwiązanie: Użyj Record<Keys, Values> lub alternatywnie podpisu z indeksem.
type Role = "admin" | "editor" | "viewer";
type Permission = "read" | "write" | "delete";
// A strictly defined key-value mapping
const rolePermissions: Record<Role, Permission[]> = {
admin: ["read", "write", "delete"],
editor: ["read", "write"],
viewer: ["read"],
};
// If the keys can be arbitrary dynamic strings:
const sessionCache: Record<string, unknown> = {};
Użycie Record<string, unknown> pozwala sprawdzać strukturę obiektu, jednocześnie zmuszając każdego, kto go używa, do weryfikacji tego, czym faktycznie jest dane wartości, zanim je edytuje.
Rozwiązanie 5: Stany zmieniające strukturę (Discriminated Unions)
Sytuacja: Proces przechodzi przez różne fazy — np. idle, loading, success i error — a każda z tych faz zawiera własny rodzaj danych.
Problematyczne podejście:
interface RequestState {
status: any;
data?: any;
error?: any;
}
Modellowanie tego za pomocą pojedynczej, luźnej interfejsu oznacza, że każda właściwość staje się opcjonalna, zmuszając cię do używania łańcuchów ?. we wszystkich miejscach w kodzie. Co gorsza, nic nie powstrzymuje cię przed znalezieniem się w bezsensownej sytuacji, gdy zarówno data, jak i error są wypełniane jednocześnie.
Prawidłowe rozwiązanie: Skorzystaj z zjednoczeń dyskryminowanych.
Daj każdemu możliwemu stanowi wspólne pole literówkowe — zwykle o nazwie status lub type — które TypeScript może wykorzystać do odróżniania różnych wariantów:
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; error: Error };
function renderUI(state: RequestState) {
switch (state.status) {
case "idle":
return "Click start";
case "loading":
return "Loading...";
case "success":
// TypeScript automatically knows state.data exists here!
return `Loaded ${state.data.length} items`;
case "error":
// TypeScript automatically knows state.error exists here!
return `Failed: ${state.error.message}`;
}
}
Rozwiązanie 6: Wszechstronne sprawdzanie za pomocą never
Sytuacja: Chcesz, aby sam kompilator zaznaczył błąd, gdy kolega dołączy nowy przypadek do typu zjednoczenia, ale zapomni zaktualizować każdy łańcuch switch lub if/else, który się nim zajmuje.
Problematyczne podejście: Ucieczka do ogólnego rozwiązania typu default: return any lub całkowite zignorowanie dodatkowego przypadku.
Prawidłowe rozwiązanie: Przypisanie tego, co pozostaje, do typu never.
never opisuje wartość, która logicznie nie może wystąpić. Gdy instrukcja switch obejmie wszystkie elementy unii, to, co pozostanie w gałęzi default, zostanie sprowadzone do typu never. Jeśli ktoś później rozszerzy unię o nową wariantę, kompilator natychmiast zaznaczy błąd na tej linii, ponieważ pozostała wartość już nie pasuje do typu never:
type NotificationType = "email" | "sms" | "push";
function sendNotification(type: NotificationType) {
switch (type) {
case "email":
// Handle email
break;
case "sms":
// Handle SMS
break;
case "push":
// Handle push notification
break;
default: {
// If someone adds "in_app" to NotificationType and forgets to update this function,
// TypeScript will raise a compile-time error right here.
const _exhaustiveCheck: never = type;
return _exhaustiveCheck;
}
}
}
Szybki przewodnik: Zastępowanie any
Jak to wdrożyć w całym zespole
Zrozumienie tych zamienników rozwiązuje tylko połowę problemu — druga połowa polega na zapobieganiu temu, by any mogło się ponownie zakraść, gdy zbliża się termin.
Wbuduj ograniczenie bezpośrednio do konfiguracji projektu, aby było automatycznie egzekwowane:
W pliku tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true
}
}
W konfiguracji ESLint (.eslintrc.json):
{
"rules": {
"@typescript-eslint/no-explicit-any": "error"
}
}
Głównym celem TypeScript jest zapewnienie pewności siebie — pewności podczas refaktoryzacji, pewności podczas wdrażania, a także autodopasowywanie tekstu w edytorze, które oszczędza konieczność ciągłego zaglądania do dokumentacji. Wykluczenie typu any może na początku wydawać się ograniczające. Jednak gdy jego miejsce zajmą typ unknown, generyki oraz zespoły typów rozróżnionych, kod zaczyna sam się tłumaczyć, błędy w czasie wykonywania drastycznie się zmniejszają, a kompilator w końcu funkcjonuje tak, jak został zaprojektowany — jako zabezpieczenie.