Menu

Interfejsy w TypeScript: składnia, pola opcjonalne, metody

Interfejs nadaje nazwę kształtowi obiektu: jakie ma właściwości i jakiego typu jest każda z nich. Dowiedz się, jak go zadeklarować, czym są właściwości opcjonalne i readonly, metody, sygnatury indeksu, rozszerzanie, implementowanie w klasie, łączenie deklaracji, interfejsy generyczne i jak nadać interfejsowi wartości domyślne.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

Interfejs nadaje nazwę kształtowi obiektu: właściwościom, które musi mieć, i typowi każdej z nich. Po zadeklarowaniu używasz tej nazwy jako typu, a kompilator sprawdza względem niej każdy obiekt, który przekazujesz, zwracasz albo przypisujesz.

Ostatnie wywołanie to błąd kompilacji TS2741. Interfejsy są usuwane podczas kompilacji kodu: w wynikowym JavaScripcie nie ma śladu User i nic nie sprawdza kształtu w czasie działania.

Deklarowanie interfejsu

Składnia to słowo kluczowe interface, nazwa (zwyczajowo w PascalCase) i ciało z listą składowych. Składowe można oddzielać średnikami, przecinkami albo po prostu nowymi liniami; średniki to najczęstszy styl.

interface Product {
  sku: string;               // required property
  price: number;
  tags: string[];            // array property
  dimensions: {              // nested object type
    width: number;
    height: number;
  };
  discount?: number;         // optional property
  readonly createdAt: Date;  // cannot be reassigned
  label(): string;           // method
}

Interfejs to typ, a nie wartość. Nie da się utworzyć jego instancji przez new, nie ma wartości domyślnych, a obj instanceof Product to błąd TS2693 ('Product' only refers to a type, but is being used as a value here). Żeby sprawdzić kształt w czasie działania, napisz type guard.

Typowanie strukturalne i sprawdzanie nadmiarowych właściwości

TypeScript porównuje kształty, a nie nazwy. Każdy obiekt z wymaganymi właściwościami pasuje do interfejsu, niezależnie od tego, czy został z nim zadeklarowany. Dodatkowe właściwości są dozwolone, z jednym wyjątkiem: literał obiektowy zapisany bezpośrednio tam, gdzie oczekiwany jest interfejs, przechodzi sprawdzenie nadmiarowych właściwości, bo nieznany klucz w takim miejscu to prawie zawsze literówka.

Ten błąd (TS2353) wyłapuje { id: 1, name: "a", emial: "x" } dla typu User: kompilator podpowiada nawet Did you mean to write 'email'? (TS2561).

Właściwości opcjonalne i readonly

? po nazwie czyni właściwość opcjonalną: obiekt może ją pominąć, a jej odczyt daje T | undefined. readonly zabrania ponownego przypisania właściwości po utworzeniu obiektu.

Widać tu dwa ograniczenia. Po pierwsze, readonly działa tylko w czasie kompilacji: dwie linie oznaczone @ts-expect-error i tak się wykonują po kliknięciu Run, i to z powodzeniem, a ostatnie linie zmieniają apiUrl przez referencję otypowaną bez readonly. Po drugie, jest płytkie: readonly hosts: string[] zablokowałoby ponowne przypisanie hosts, ale nadal pozwalałoby na hosts.push(...), dlatego sama tablica ma typ readonly string[]. readonly dokumentuje i egzekwuje zamiar w otypowanym kodzie; niczego nie zamraża. Utility type Readonly<T> sprawia, że wszystkie właściwości istniejącego interfejsu stają się naraz readonly.

Metody i właściwości funkcyjne

Metodę można zapisać jako sygnaturę metody, name(params): ReturnType, albo jako właściwość przechowującą funkcję, name: (params) => ReturnType. Wywołuje się je tak samo.

Różnica jest subtelna: przy strictFunctionTypes (część strict) parametry właściwości o typie funkcji są sprawdzane ściśle, a sygnatury metod luźniej (biwariantnie), więc forma właściwości wyłapuje kilka błędów więcej. Składnia metody jest krótsza i częściej spotykana; obie są w porządku.

Interfejs może też opisywać coś, co da się wywołać albo skonstruować, za pomocą sygnatury wywołania albo sygnatury konstrukcji:

interface Formatter {
  (value: number): string; // call signature: the object is a function
  locale: string;          // and it also has a property
}

interface PointConstructor {
  new (x: number, y: number): { x: number; y: number }; // construct signature
}

Sygnatury indeksu

Gdy nazwy właściwości nie są znane z góry, sygnatura indeksu opisuje je wszystkie naraz: [key: string]: T oznacza „dowolny klucz typu string, każdy z wartością T”.

Ostatnie linie pokazują haczyk: odczyt nieistniejącego klucza ma typ number, a nie number | undefined. Opcja kompilatora noUncheckedIndexedAccess dodaje | undefined do każdego takiego odczytu.

Nazwane właściwości mogą stać obok sygnatury indeksu, ale muszą do niej pasować. interface Dict { [key: string]: number; name: string } to błąd TS2411, Property 'name' of type 'string' is not assignable to 'string' index type 'number'. Poszerz typ indeksu ([key: string]: number | string) albo przenieś dynamiczną część do osobnej właściwości. Dla prostych map klucz/wartość Record<string, number> mówi to samo w jednej linii.

Rozszerzanie interfejsu

extends buduje nowy interfejs z jednego lub kilku istniejących. Potomek ma wszystkie składowe rodzica i do tego własne:

interface Animal {
  name: string;
}

interface Pet extends Animal {
  owner: string;
}

interface Trained {
  commands: string[];
}

interface ServiceDog extends Pet, Trained {
  certifiedUntil: Date;
}

// ServiceDog requires: name, owner, commands, certifiedUntil

Potomek może ponownie zadeklarować właściwość rodzica tylko ze zgodnym (węższym) typem, na przykład kind: "dog" tam, gdzie rodzic ma kind: string. Reguły i sposób rozszerzania aliasów typów opisuje strona o extends.

Implementowanie interfejsu w klasie

class X implements Shape prosi kompilator o sprawdzenie, czy klasa ma wszystko, czego wymaga interfejs. Brakująca składowa to błąd przy deklaracji klasy:

index.ts(7,7): error TS2420: Class 'Circle' incorrectly implements interface 'Shape'.
  Property 'area' is missing in type 'Circle' but required in type 'Shape'.

Po dodaniu area() kilka klas, a nawet zwykły obiekt, może służyć jako Shape:

implements to tylko sprawdzenie. Nie dodaje składowych do klasy i nie typuje za ciebie parametrów jej metod: greet(name) {} w klasie implementującej greet(name: string): string to nadal błąd TS7006, Parameter 'name' implicitly has an 'any' type. Klasa może implementować kilka interfejsów: class A implements B, C.

Łączenie deklaracji

Dwukrotne zadeklarowanie interfejsu o tej samej nazwie w tym samym zasięgu łączy obie deklaracje w jedną. Aliasy typów tego nie potrafią (drugi type o tej samej nazwie to błąd zduplikowanego identyfikatora).

interface Settings {
  theme: string;
}

interface Settings {
  fontSize: number;
}

// Settings now requires both properties
const s: Settings = { theme: "dark", fontSize: 14 };

W kodzie aplikacji rzadko o to chodzi, a przypadkowe połączenie potrafi zmylić. Prawdziwe zastosowanie to dodawanie składowych do typów, które nie są twoje: opcji biblioteki albo globalnego obiektu, takiego jak Window. Wewnątrz modułu owiń deklarację w declare global:

declare global {
  interface Window {
    analytics: { track(event: string): void };
  }
}

export {};

Od tej chwili window.analytics.track("signup") przechodzi sprawdzanie typów w całym projekcie. Na tym samym mechanizmie opierają się pakiety z definicjami typów; zobacz pliki deklaracji.

Wartości domyślne właściwości interfejsu

Interfejs nie może przechowywać wartości domyślnych, bo opisuje typy i jest usuwany w czasie działania. size?: "sm" | "md" = "md" to błąd TS1246, An interface property cannot have an initializer. Uczyń właściwość opcjonalną i uzupełnij wartość domyślną tam, gdzie używasz obiektu:

Wartości domyślne przy destrukturyzacji są bezpieczniejsze: działają zawsze, gdy wartość to undefined, także przy jawnym size: undefined. Wersja ze spreadem kopiuje to jawne undefined na wartość domyślną, a jej wynik nadal ma typ, jakby size było zawsze ustawione. Jeśli potrzebujesz takiej gwarancji od typów, włącz exactOptionalPropertyTypes, które czyni size: undefined błędem kompilacji dla opcjonalnego size?: .... Klasa z zainicjalizowanymi polami to druga opcja, gdy obiekt potrzebuje też zachowania.

Interfejsy generyczne

Interfejs może przyjmować parametry typu, dzięki czemu jedna deklaracja działa dla wielu typów zawartości:

interface ApiResponse<T> {
  ok: boolean;
  data: T;
  error?: string;
}

interface Page<T> {
  items: T[];
  nextCursor?: string;
}

interface User {
  id: number;
  name: string;
}

const res: ApiResponse<Page<User>> = {
  ok: true,
  data: { items: [{ id: 1, name: "Ada" }], nextCursor: "abc" },
};

ApiResponse<Page<User>> czyta się jako „odpowiedź, której dane są stroną użytkowników”. Biblioteka standardowa jest ich pełna: Array<T>, Promise<T> i Map<K, V> to interfejsy generyczne.

Interfejs kontra alias typu

Alias type może opisywać ten sam kształt obiektu, a dla zwykłych typów obiektowych oba są wymienne. Tylko interfejs można łączyć; tylko alias typu może nazwać unię, krotkę albo mapped type czy conditional type. Praktyczna reguła z handbooka TypeScript: używaj interface, dopóki nie potrzebujesz czegoś, co ma tylko type. Pełne porównanie jest na stronie o interface vs type, łącznie z różnicą przy Record<string, ...>, która zaskakuje większość osób.

Najczęściej zadawane pytania

Czym jest interfejs w TypeScript?

Interfejs to nazwany opis kształtu obiektu: nazw jego właściwości, ich typów, tego, które są opcjonalne albo readonly, oraz jego metod. Kompilator sprawdza, czy wartości używane jako ten interfejs mają taki kształt. Interfejsy istnieją tylko w czasie kompilacji; nie generują żadnego JavaScriptu.

Jak ustawić wartość domyślną w interfejsie TypeScript?

Nie da się: interfejs opisuje typy, a nie wartości, więc size: "md" = ... to niepoprawna składnia. Oznacz właściwość jako opcjonalną (size?: "sm" | "md") i zastosuj wartość domyślną tam, gdzie używasz obiektu, zwykle przez wartości domyślne przy destrukturyzacji parametrów funkcji: function render({ size = "md" }: Options). Rozwinięcie obiektu z wartościami domyślnymi ({ ...DEFAULTS, ...options }) też działa, ale jawne undefined w options nadpisuje wartość domyślną.

Jak sprawdzić w czasie działania, czy obiekt implementuje interfejs?

Nie ma wbudowanego sposobu, bo interfejsy są usuwane podczas kompilacji: obj instanceof User to błąd TS2693 ('User' only refers to a type, but is being used as a value here). Napisz funkcję type guard, która sprawdza właściwości, function isUser(x: unknown): x is User { ... }, albo waliduj przez bibliotekę schematów.

Czy interfejs może rozszerzać kilka interfejsów?

Tak. Wypisz je po extends, oddzielone przecinkami: interface ServiceDog extends Pet, Trained { ... }. Nowy interfejs ma wszystkie składowe każdego rodzica i do tego własne. Jeśli dwaj rodzice deklarują tę samą właściwość z niezgodnymi typami, deklaracja jest błędem.

Czym różni się interfejs od klasy w TypeScript?

Klasa istnieje w czasie działania: ma konstruktor, implementacje metod, a new tworzy z niej obiekty. Interfejs tylko opisuje kształt dla kompilatora i jest usuwany z wynikowego JavaScriptu. Klasa może zadeklarować implements SomeInterface, żeby kompilator sprawdził, czy pasuje, a każdy zwykły obiekt o właściwym kształcie też pasuje do interfejsu.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ