Odkryj dokładne typy w TypeScript do ścisłego dopasowania kształtu obiektów, zapobiegając nieoczekiwanym właściwościom i zapewniając solidność kodu. Poznaj praktyczne zastosowania i najlepsze praktyki.
Dokładne typy w TypeScript: Ścisłe dopasowanie kształtu obiektu dla solidnego kodu
TypeScript, nadzbiór JavaScriptu, wprowadza statyczne typowanie do dynamicznego świata tworzenia aplikacji internetowych. Chociaż TypeScript oferuje znaczące korzyści w zakresie bezpieczeństwa typów i utrzymania kodu, jego system typowania strukturalnego może czasami prowadzić do nieoczekiwanego zachowania. W tym miejscu pojawia się koncepcja „dokładnych typów”. Mimo że TypeScript nie ma wbudowanej funkcji o nazwie „dokładne typy”, możemy osiągnąć podobne zachowanie poprzez połączenie funkcji i technik TypeScript. Ten wpis na blogu zagłębi się w sposoby wymuszania ściślejszego dopasowania kształtu obiektów w TypeScript, aby poprawić solidność kodu i zapobiegać częstym błędom.
Zrozumienie typowania strukturalnego w TypeScript
TypeScript wykorzystuje typowanie strukturalne (znane również jako duck typing), co oznacza, że kompatybilność typów jest określana na podstawie składowych typów, a nie ich zadeklarowanych nazw. Jeśli obiekt ma wszystkie właściwości wymagane przez dany typ, jest uważany za kompatybilny z tym typem, niezależnie od tego, czy posiada dodatkowe właściwości.
Na przykład:
interface Point {
x: number;
y: number;
}
const myPoint = { x: 10, y: 20, z: 30 };
function printPoint(point: Point) {
console.log(`X: ${point.x}, Y: ${point.y}`);
}
printPoint(myPoint); // To działa poprawnie, mimo że myPoint ma właściwość 'z'
W tym scenariuszu TypeScript pozwala na przekazanie `myPoint` do `printPoint`, ponieważ zawiera on wymagane właściwości `x` i `y`, mimo że ma dodatkową właściwość `z`. Chociaż ta elastyczność może być wygodna, może również prowadzić do subtelnych błędów, jeśli nieumyślnie przekażemy obiekty z nieoczekiwanymi właściwościami.
Problem z nadmiarowymi właściwościami
Pobłażliwość typowania strukturalnego może czasami maskować błędy. Rozważmy funkcję, która oczekuje obiektu konfiguracyjnego:
interface Config {
apiUrl: string;
timeout: number;
}
function setup(config: Config) {
console.log(`API URL: ${config.apiUrl}`);
console.log(`Timeout: ${config.timeout}`);
}
const myConfig = { apiUrl: "https://api.example.com", timeout: 5000, typo: true };
setup(myConfig); // TypeScript nie zgłasza tutaj błędu!
console.log(myConfig.typo); //wypisuje true. Dodatkowa właściwość istnieje po cichu
W tym przykładzie `myConfig` ma dodatkową właściwość `typo`. TypeScript nie zgłasza błędu, ponieważ `myConfig` wciąż spełnia interfejs `Config`. Jednak literówka nigdy nie zostaje wychwycona, a aplikacja może nie zachowywać się zgodnie z oczekiwaniami, jeśli literówka miała być `typoo`. Te pozornie nieistotne problemy mogą przerodzić się w poważne bóle głowy podczas debugowania złożonych aplikacji. Brakująca lub błędnie napisana właściwość może być szczególnie trudna do wykrycia w przypadku obiektów zagnieżdżonych w innych obiektach.
Podejścia do wymuszania dokładnych typów w TypeScript
Chociaż prawdziwe „dokładne typy” nie są bezpośrednio dostępne w TypeScript, oto kilka technik, aby osiągnąć podobne rezultaty i wymusić ściślejsze dopasowanie kształtu obiektu:
1. Używanie asercji typów z `Omit`
Typ narzędziowy `Omit` pozwala na utworzenie nowego typu poprzez wykluczenie określonych właściwości z istniejącego typu. W połączeniu z asercją typu może to pomóc w zapobieganiu nadmiarowym właściwościom.
interface Point {
x: number;
y: number;
}
const myPoint = { x: 10, y: 20, z: 30 };
// Utwórz typ, który zawiera tylko właściwości z Point
const exactPoint: Point = myPoint as Omit & Point;
// Error: Type '{ x: number; y: number; z: number; }' is not assignable to type 'Point'.
// Object literal may only specify known properties, and 'z' does not exist in type 'Point'.
function printPoint(point: Point) {
console.log(`X: ${point.x}, Y: ${point.y}`);
}
//Poprawka
const myPointCorrect = { x: 10, y: 20 };
const exactPointCorrect: Point = myPointCorrect as Omit & Point;
printPoint(exactPointCorrect);
To podejście zgłasza błąd, jeśli `myPoint` ma właściwości, które nie są zdefiniowane w interfejsie `Point`.
Wyjaśnienie: `Omit
2. Używanie funkcji do tworzenia obiektów
Możesz utworzyć funkcję fabrykującą, która akceptuje tylko właściwości zdefiniowane w interfejsie. To podejście zapewnia silne sprawdzanie typów w momencie tworzenia obiektu.
interface Config {
apiUrl: string;
timeout: number;
}
function createConfig(config: Config): Config {
return {
apiUrl: config.apiUrl,
timeout: config.timeout,
};
}
const myConfig = createConfig({ apiUrl: "https://api.example.com", timeout: 5000 });
//To się nie skompiluje:
//const myConfigError = createConfig({ apiUrl: "https://api.example.com", timeout: 5000, typo: true });
//Argument of type '{ apiUrl: string; timeout: number; typo: true; }' is not assignable to parameter of type 'Config'.
// Object literal may only specify known properties, and 'typo' does not exist in type 'Config'.
Zwracając obiekt skonstruowany tylko z właściwości zdefiniowanych w interfejsie `Config`, zapewniasz, że żadne dodatkowe właściwości się nie wkradną. To sprawia, że tworzenie konfiguracji jest bezpieczniejsze.
3. Używanie strażników typów (Type Guards)
Strażnicy typów (type guards) to funkcje, które zawężają typ zmiennej w określonym zakresie. Chociaż nie zapobiegają bezpośrednio nadmiarowym właściwościom, mogą pomóc w ich jawnym sprawdzaniu i podejmowaniu odpowiednich działań.
interface User {
id: number;
name: string;
}
function isUser(obj: any): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj && typeof obj.id === 'number' &&
'name' in obj && typeof obj.name === 'string' &&
Object.keys(obj).length === 2 //sprawdzenie liczby kluczy. Uwaga: jest to kruche i zależy od dokładnej liczby kluczy w User.
);
}
const potentialUser1 = { id: 123, name: "Alice" };
const potentialUser2 = { id: 456, name: "Bob", extra: true };
if (isUser(potentialUser1)) {
console.log("Valid User:", potentialUser1.name);
} else {
console.log("Invalid User");
}
if (isUser(potentialUser2)) {
console.log("Valid User:", potentialUser2.name); //Ten kod się nie wykona
} else {
console.log("Invalid User");
}
W tym przykładzie strażnik typu `isUser` sprawdza nie tylko obecność wymaganych właściwości, ale także ich typy i *dokładną* liczbę właściwości. To podejście jest bardziej jawne i pozwala na elegancką obsługę nieprawidłowych obiektów. Jednak sprawdzanie liczby właściwości jest kruche. Za każdym razem, gdy `User` zyskuje/traci właściwości, sprawdzenie musi zostać zaktualizowane.
4. Wykorzystanie `Readonly` i `as const`
Chociaż `Readonly` zapobiega modyfikacji istniejących właściwości, a `as const` tworzy krotkę lub obiekt tylko do odczytu, w którym wszystkie właściwości są głęboko tylko do odczytu i mają typy literalne, można ich użyć do stworzenia bardziej rygorystycznej definicji i sprawdzania typów w połączeniu z innymi metodami. Jednak żadne z nich samo w sobie nie zapobiega nadmiarowym właściwościom.
interface Options {
width: number;
height: number;
}
//Utwórz typ Readonly
type ReadonlyOptions = Readonly;
const options: ReadonlyOptions = { width: 100, height: 200 };
//options.width = 300; //error: Cannot assign to 'width' because it is a read-only property.
//Użycie as const
const config = { api_url: "https://example.com", timeout: 3000 } as const;
//config.timeout = 5000; //error: Cannot assign to 'timeout' because it is a read-only property.
//Jednak nadmiarowe właściwości są nadal dozwolone:
const invalidOptions: ReadonlyOptions = { width: 100, height: 200, depth: 300 }; //brak błędu. Nadal pozwala na nadmiarowe właściwości.
interface StrictOptions {
readonly width: number;
readonly height: number;
}
//To teraz spowoduje błąd:
//const invalidStrictOptions: StrictOptions = { width: 100, height: 200, depth: 300 };
//Type '{ width: number; height: number; depth: number; }' is not assignable to type 'StrictOptions'.
// Object literal may only specify known properties, and 'depth' does not exist in type 'StrictOptions'.
To poprawia niezmienność, ale zapobiega tylko mutacji, a nie istnieniu dodatkowych właściwości. W połączeniu z `Omit` lub podejściem funkcyjnym staje się bardziej skuteczne.
5. Używanie bibliotek (np. Zod, io-ts)
Biblioteki takie jak Zod i io-ts oferują potężne możliwości walidacji typów w czasie wykonania i definiowania schematów. Pozwalają one na definiowanie schematów, które precyzyjnie opisują oczekiwany kształt danych, w tym zapobiegają nadmiarowym właściwościom. Chociaż dodają zależność w czasie wykonania, oferują bardzo solidne i elastyczne rozwiązanie.
Przykład z Zod:
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
});
type User = z.infer;
const validUser = { id: 1, name: "John" };
const invalidUser = { id: 2, name: "Jane", extra: true };
const parsedValidUser = UserSchema.parse(validUser);
console.log("Parsed Valid User:", parsedValidUser);
try {
const parsedInvalidUser = UserSchema.parse(invalidUser);
console.log("Parsed Invalid User:", parsedInvalidUser); // Ten kod nie zostanie osiągnięty
} catch (error) {
console.error("Validation Error:", error.errors);
}
Metoda `parse` Zod zgłosi błąd, jeśli dane wejściowe nie są zgodne ze schematem, skutecznie zapobiegając nadmiarowym właściwościom. Zapewnia to walidację w czasie wykonania, a także generuje typy TypeScript ze schematu, zapewniając spójność między definicjami typów a logiką walidacji w czasie wykonania.
Najlepsze praktyki wymuszania dokładnych typów
Oto kilka najlepszych praktyk, które warto wziąć pod uwagę przy wymuszaniu ściślejszego dopasowania kształtu obiektu w TypeScript:
- Wybierz odpowiednią technikę: Najlepsze podejście zależy od Twoich konkretnych potrzeb i wymagań projektu. W prostych przypadkach mogą wystarczyć asercje typów z `Omit` lub funkcje fabrykujące. W bardziej złożonych scenariuszach lub gdy wymagana jest walidacja w czasie wykonania, rozważ użycie bibliotek takich jak Zod lub io-ts.
- Bądź konsekwentny: Stosuj wybrane podejście konsekwentnie w całym kodzie, aby utrzymać jednolity poziom bezpieczeństwa typów.
- Dokumentuj swoje typy: Jasno dokumentuj swoje interfejsy i typy, aby komunikować oczekiwany kształt danych innym deweloperom.
- Testuj swój kod: Pisz testy jednostkowe, aby zweryfikować, czy Twoje ograniczenia typów działają zgodnie z oczekiwaniami i czy Twój kod elegancko obsługuje nieprawidłowe dane.
- Rozważ kompromisy: Wymuszanie ściślejszego dopasowania kształtu obiektu może uczynić Twój kod bardziej solidnym, ale może również wydłużyć czas разработки. Zważ korzyści w stosunku do kosztów i wybierz podejście, które ma najwięcej sensu dla Twojego projektu.
- Stopniowe wdrażanie: Jeśli pracujesz nad dużą, istniejącą bazą kodu, rozważ stopniowe wdrażanie tych technik, zaczynając od najbardziej krytycznych części aplikacji.
- Preferuj interfejsy zamiast aliasów typów przy definiowaniu kształtów obiektów: Interfejsy są generalnie preferowane, ponieważ wspierają scalanie deklaracji (declaration merging), co może być przydatne do rozszerzania typów w różnych plikach.
Przykłady z życia wzięte
Przyjrzyjmy się kilku rzeczywistym scenariuszom, w których dokładne typy mogą być korzystne:
- Ładunki żądań API: Wysyłając dane do API, kluczowe jest upewnienie się, że ładunek jest zgodny z oczekiwanym schematem. Wymuszanie dokładnych typów może zapobiegać błędom spowodowanym wysyłaniem nieoczekiwanych właściwości. Na przykład wiele API do przetwarzania płatności jest niezwykle wrażliwych na nieoczekiwane dane.
- Pliki konfiguracyjne: Pliki konfiguracyjne często zawierają dużą liczbę właściwości, a literówki mogą być powszechne. Używanie dokładnych typów może pomóc w wychwyceniu tych literówek na wczesnym etapie. Jeśli konfigurujesz lokalizacje serwerów we wdrożeniu chmurowym, literówka w ustawieniu lokalizacji (np. eu-west-1 vs. eu-wet-1) stanie się niezwykle trudna do debugowania, jeśli nie zostanie wychwycona z góry.
- Potoki transformacji danych: Podczas transformacji danych z jednego formatu na inny, ważne jest, aby upewnić się, że dane wyjściowe są zgodne z oczekiwanym schematem.
- Kolejki komunikatów: Wysyłając wiadomości przez kolejkę komunikatów, ważne jest, aby upewnić się, że ładunek wiadomości jest prawidłowy i zawiera poprawne właściwości.
Przykład: Konfiguracja internacjonalizacji (i18n)
Wyobraź sobie zarządzanie tłumaczeniami dla aplikacji wielojęzycznej. Możesz mieć obiekt konfiguracyjny taki jak ten:
interface Translation {
greeting: string;
farewell: string;
}
interface I18nConfig {
locale: string;
translations: Translation;
}
const englishConfig: I18nConfig = {
locale: "en-US",
translations: {
greeting: "Hello",
farewell: "Goodbye"
}
};
//To będzie problem, ponieważ istnieje nadmiarowa właściwość, po cichu wprowadzająca błąd.
const spanishConfig: I18nConfig = {
locale: "es-ES",
translations: {
greeting: "Hola",
farewell: "Adiós",
typo: "unintentional translation"
}
};
//Rozwiązanie: Użycie Omit
const spanishConfigCorrect: I18nConfig = {
locale: "es-ES",
translations: {
greeting: "Hola",
farewell: "Adiós"
} as Omit & Translation
};
Bez dokładnych typów, literówka w kluczu tłumaczenia (jak dodanie pola `typo`) mogłaby pozostać niezauważona, co prowadziłoby do brakujących tłumaczeń w interfejsie użytkownika. Wymuszając ściślejsze dopasowanie kształtu obiektu, możesz wychwycić te błędy podczas rozwoju i zapobiec ich dotarciu do produkcji.
Wnioski
Chociaż TypeScript nie ma wbudowanych „dokładnych typów”, można osiągnąć podobne rezultaty, używając kombinacji funkcji i technik TypeScript, takich jak asercje typów z `Omit`, funkcje fabrykujące, strażnicy typów, `Readonly`, `as const` oraz zewnętrzne biblioteki, takie jak Zod i io-ts. Wymuszając ściślejsze dopasowanie kształtu obiektu, można poprawić solidność kodu, zapobiegać częstym błędom i uczynić aplikacje bardziej niezawodnymi. Pamiętaj, aby wybrać podejście, które najlepiej odpowiada Twoim potrzebom i stosować je konsekwentnie w całym kodzie. Starannie rozważając te podejścia, możesz przejąć większą kontrolę nad typami w swojej aplikacji i zwiększyć jej długoterminową łatwość utrzymania.