keyof, typeof und indexed access types in TypeScript
1. Übersicht
Wenn die Codebasis wächst, wirst du auf Szenarien treffen, in denen dieselbe Menge von Keys, etwa Bestellstatus, an mehreren Stellen auftaucht: in Objekten, in Union Types, in Funktionsparametern. Eine Stelle zu ändern und die andere zu vergessen ist ein Bug.
TypeScript hat drei Tools, um nur einmal zu deklarieren und den Rest herzuleiten. keyof nimmt die Menge der Keys eines Types. typeof nimmt den Type eines Wertes. Indexed access type (T["key"]) nimmt den Type einer Eigenschaft.
Nach diesem Artikel wirst du wissen, wie du diese drei Tools kombinierst, um sichere generische Funktionen zu schreiben und einen Event Bus mit strengem Type-System zu bauen. Du wirst auch die häufigen Fehler kennen, die bei der Verwendung auftreten.
2. Vorbereitung
Der Artikel verwendet TypeScript 5.x und Vitest zum Schreiben von Tests. Du installierst sie wie folgt:
npm install --save-dev typescript vitest
Du solltest strict in tsconfig.json aktivieren. Falls nicht aktiviert, werden viele Fehler bezüglich undefined nicht gemeldet:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true
}
}
3. Der keyof Operator
3.1. Grundlegende Verwendung
keyof T gibt eine Union aus den Namen aller Eigenschaften von T zurück:
type Order = {
id: string;
total: number;
status: "pending" | "paid" | "cancelled";
note?: string;
};
type OrderKey = keyof Order;
// "id" | "total" | "status" | "note"
const sortBy: OrderKey = "total"; // OK
const wrong: OrderKey = "price"; // Fehler
Die letzte Zeile lässt sich nicht kompilieren:
error TS2322: Type '"price"' is not assignable to type 'keyof Order'.
Du musst die Union "id" | "total" | ... nicht manuell eintippen. Wenn du eine neue Eigenschaft zu Order hinzufügst, aktualisiert sich OrderKey automatisch.
3.2. keyof mit Index Signature
Wenn der Type eine Index Signature verwendet, könnte das Ergebnis überraschend sein:
type PriceTable = { [sku: string]: number };
type PriceKey = keyof PriceTable; // string | number
type ByIndex = { [index: number]: string };
type IndexKey = keyof ByIndex; // number
PriceKey ist string | number und nicht nur string. Der Grund ist, dass in JavaScript obj[1] und obj["1"] auf dieselbe Eigenschaft zugreifen. Daher erlaubt TypeScript sowohl Keys vom Typ number.
3.3. keyof in Generics
keyof wirkt am besten zusammen mit Generic Constraints:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const order: Order = { id: "ORD-1", total: 250_000, status: "paid" };
const total = getProperty(order, "total"); // number
const status = getProperty(order, "status"); // "pending" | "paid" | "cancelled"
getProperty(order, "price"); // Kompilierungsfehler
Diese Funktion erledigt mehrere Dinge:
K extends keyof Tbeschränktkey, sodass es nur echte Eigenschaftsnamen vonobjakzeptiert.- Der Rückgabetyp
T[K]ist ein indexed access type, hierüber sprechen wir in Abschnitt 5. Dank diesem erhält jeder Funktionsaufruf den korrekten Typ der entsprechenden Eigenschaft. - Der Aufruf mit
"total"ergibtnumber. Der Aufruf mit"status"ergibt ein Union Literal. Hier gibt es keineany.
4. Der typeof Operator im Type-Kontext
JavaScript hat typeof, das zur Laufzeit ausgeführt wird und Strings wie "string" oder "object" zurückgibt. TypeScript fügt eine zweite Bedeutung hinzu: wenn es in einer Type-Position steht, gibt typeof x dir den Type der Variablen x.
4.1. Type aus einem Wert gewinnen
const defaultCustomer = {
name: "Nguyễn Văn An",
email: "an@example.com",
loyaltyPoints: 0,
};
type Customer = typeof defaultCustomer;
// { name: string; email: string; loyaltyPoints: number }
function createCustomer(input: Partial<Customer>): Customer {
return { ...defaultCustomer, ...input };
}
console.log(createCustomer({ name: "Trần Thị Bình" }));
Ausgabe:
{ name: 'Trần Thị Bình', email: 'an@example.com', loyaltyPoints: 0 }
Hier ist der Standardwert die einzige Quelle der Wahrheit. Wenn du ein Feld zu defaultCustomer hinzufügst, hat Customer auch dieses Feld.
Bei Funktionen kombinierst du typeof mit eingebauten Utility Types:
function loadConfig() {
return { apiUrl: "https://api.shop.local", timeoutMs: 5000, retries: 3 };
}
type AppConfig = ReturnType<typeof loadConfig>;
// { apiUrl: string; timeoutMs: number; retries: number }
4.2. as const und typeof
Standardmäßig “verbreitert” TypeScript Literals: "pending" wird zu string, Arrays werden zu string[]. Wenn du as const hinzufügst, bleiben Literals erhalten und werden als readonly gekennzeichnet:
const ORDER_STATUSES = ["pending", "paid", "shipped", "cancelled"] as const;
type OrderStatusList = typeof ORDER_STATUSES;
// readonly ["pending", "paid", "shipped", "cancelled"]
Ohne as const ist typeof ORDER_STATUSES nur string[] und du verlierst alle Literal-Informationen.
4.3. Kombination von keyof typeof
Dies ist eine sehr häufige Schreibweise, wenn du ein konstantes Objekt hast und die Union seiner Keys haben möchtest:
const SHIPPING_FEES = {
standard: 30_000,
express: 60_000,
sameDay: 120_000,
} as const;
type ShippingMethod = keyof typeof SHIPPING_FEES;
// "standard" | "express" | "sameDay"
function shippingFee(method: ShippingMethod): number {
return SHIPPING_FEES[method];
}
console.log(shippingFee("express"));
60000
Die Leserichtung ist von innen nach außen. typeof SHIPPING_FEES nimmt den Type des Objekts, dann nimmt keyof die Keys dieses Types. Du kannst nicht keyof SHIPPING_FEES schreiben, da SHIPPING_FEES ein Wert und kein Type ist.
5. Indexed Access Types
5.1. Den Type einer Eigenschaft abrufen
Die Syntax T["key"] ähnelt dem Zugriff auf eine Eigenschaft, findet aber auf der Type-Ebene statt:
type OrderStatus = Order["status"]; // "pending" | "paid" | "cancelled"
type OrderNote = Order["note"]; // string | undefined
type IdOrTotal = Order["id" | "total"]; // string | number
type AnyOrderValue = Order[keyof Order]; // Union Type aller Eigenschaftswerte
Es gibt drei Punkte zu beachten:
- Eine optionale Eigenschaft (
note?) gibt einen Type mitundefinedzurück. - Wenn du eine Union von Keys übergibst, erhältst du eine Union der entsprechenden Types.
T[keyof T]ist die übliche Methode, um den “Type aller Werte” in einem Objekt zu erhalten.
5.2. Den Type des Array-Elements abrufen
Die Indexierung mit number gibt dir den Type des Elements:
type OrderStatusFromList = (typeof ORDER_STATUSES)[number];
// "pending" | "paid" | "shipped" | "cancelled"
function isOrderStatus(value: string): value is OrderStatusFromList {
return (ORDER_STATUSES as readonly string[]).includes(value);
}
console.log(isOrderStatus("paid"), isOrderStatus("refunded"));
true false
Das Array ORDER_STATUSES erfüllt jetzt zwei Aufgaben. Zur Laufzeit ist es eine Liste zur Validierung von API-Input. Zur Kompilierungszeit erzeugt es einen Union Type. Du deklarierst den Status nur einmal.
5.3. Verschachtelte Zugriffe
Du kannst mehrfach indexieren, um tiefer in die Struktur zu gehen:
type Invoice = {
customer: { name: string; address: { city: string; zip: string } };
lines: Array<{ sku: string; quantity: number; unitPrice: number }>;
};
type City = Invoice["customer"]["address"]["city"]; // string
type InvoiceLine = Invoice["lines"][number];
// { sku: string; quantity: number; unitPrice: number }
function lineTotal(line: InvoiceLine): number {
return line.quantity * line.unitPrice;
}
InvoiceLine wird direkt aus Invoice abgeleitet, daher musst du keinen separaten Type deklarieren und beide Seiten nicht synchron halten.
6. Praktisches Beispiel: Ein Event Bus mit strengem Type-System
Jetzt kombinieren wir alle drei Tools. Das Ziel ist ein Event Bus, bei dem die Payload jedes Events korrekt auf ihren Type geprüft wird:
type OrderEvents = {
created: { orderId: string; total: number };
paid: { orderId: string; method: "card" | "cash" };
cancelled: { orderId: string; reason: string };
};
type Handler<K extends keyof OrderEvents> = (payload: OrderEvents[K]) => void;
export class OrderEventBus {
private handlers: { [K in keyof OrderEvents]?: Handler<K>[] } = {};
on<K extends keyof OrderEvents>(event: K, handler: Handler<K>): void {
(this.handlers[event] ??= []).push(handler);
}
emit<K extends keyof OrderEvents>(event: K, payload: OrderEvents[K]): void {
this.handlers[event]?.forEach((handle) => handle(payload));
}
}
const bus = new OrderEventBus();
bus.on("paid", (p) => console.log(`Đơn ${p.orderId} thanh toán bằng ${p.method}`));
bus.emit("paid", { orderId: "ORD-42", method: "card" });
// bus.emit("paid", { orderId: "ORD-42", reason: "x" }); // Fehler: 'method' fehlt
// bus.emit("refunded", { orderId: "ORD-42" }); // Fehler: Event existiert nicht
Ausgabe:
Đơn ORD-42 thanh toán bằng card
Jeder Teil funktioniert wie folgt:
keyof OrderEventsbeschränkt Eventnamen auf die drei Werte"created" | "paid" | "cancelled".OrderEvents[K]verknüpft die Payload mit dem korrekten Event. Im Handler von"paid"hat der Parameterpmethod, aber nichtreason.- Der Mapped Type
[K in keyof OrderEvents]erstellt einen Handler-Speicher, wobei jedes Event eine eigene Liste hat. - Wenn du ein Event
refundedhinzufügen möchtest, änderst du nurOrderEvents. Alles andere aktualisiert sich automatisch.
7. Type-Tests mit Vitest
Vitest hat expectTypeOf, um Types zu testen. Du kombinierst es mit @ts-expect-error, um sicherzustellen, dass fehlerhafter Code abgefangen wird:
import { describe, it, expect, expectTypeOf, vi } from "vitest";
import { OrderEventBus } from "./order-event-bus";
type Order = { id: string; total: number; status: "pending" | "paid" };
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
describe("keyof und indexed access", () => {
const order: Order = { id: "ORD-1", total: 250_000, status: "paid" };
it("gibt den korrekten Wert und den korrekten Type zurück", () => {
const total = getProperty(order, "total");
expect(total).toBe(250_000);
expectTypeOf(total).toEqualTypeOf<number>();
expectTypeOf(getProperty(order, "status")).toEqualTypeOf<"pending" | "paid">();
});
it("lehnt nicht existierende Keys ab", () => {
// @ts-expect-error - 'price' ist kein Key von Order
getProperty(order, "price");
});
it("Event Bus ruft Handler mit korrekter Payload auf", () => {
const bus = new OrderEventBus();
const handler = vi.fn();
bus.on("created", handler);
bus.emit("created", { orderId: "ORD-7", total: 99_000 });
expect(handler).toHaveBeenCalledWith({ orderId: "ORD-7", total: 99_000 });
});
});
Du führst Tests mit zwei Befehlen aus:
npx vitest run --typecheck
npx tsc --noEmit
Hinweis:
expectTypeOfwirkt sich nur aus, wenn der Type überprüft wird, über--typecheckodertsc. Zur Laufzeit macht es fast nichts. Wenn du nurvitest runnormalerweise ausführst, bestehen Tests auch dann, wenn der Type falsch ist.
Wenn jemand "price" aus der Fehlerliste löscht, zum Beispiel indem er price zu Order hinzufügt, wird @ts-expect-error “Unused ‘@ts-expect-error’ directive” melden. Das ist ein gutes Zeichen: Deine Type-Tests schützen den API-Vertrag.
8. Häufige Fehler
Eine Variable als Type-Index verwenden. Mit const key = "total" wird das Schreiben von Order[key] zu einem Fehler führen:
error TS2749: 'key' refers to a value, but is being used as a type here. Did you mean 'typeof key'?
Die Lösung ist, Order[typeof key] zu schreiben.
typeof auf einem Ausdruck aufrufen. typeof loadConfig() ist ein Syntaxfehler. typeof akzeptiert nur Identifier oder Eigenschaftszugriffe wie typeof config.db. Bei Funktionsergebnissen verwendest du ReturnType<typeof loadConfig>.
Denken, dass Object.keys keyof T zurückgibt. Tatsächlich gibt es string[] zurück:
for (const key of Object.keys(order)) {
// order[key]; // Fehler: 'string' kann 'Order' nicht indexieren
}
TypeScript macht dies absichtlich. Types sind “strukturell”, daher kann ein Objekt zur Laufzeit mehr Eigenschaften enthalten, als der Type deklariert. Wenn du dir der Daten sicher bist, kannst du Object.keys(order) as (keyof Order)[] casten. Aber verstehe, dass du dich damit statt des Compilers verbürgst.
as const vergessen. Ohne es ist (typeof ORDER_STATUSES)[number] nur string, und alle Vorteile des Union Literals verschwinden, ohne dass ein Fehler gemeldet wird.
Hinweis: Missbrauche dies nicht. Ketten wie
Invoice["customer"]["address"]["city"]sind sinnvoll, wenn der Type wirklich vonInvoiceabhängt. WennAddressein unabhängiges Konzept ist und an vielen Stellen verwendet wird, deklariere es als benannten Type. Der Code wird lesbarer und Fehlermeldungen werden prägnanter.
9. Fazit
Die drei Tools in diesem Artikel erfüllen drei verschiedene Aufgaben:
keyofnimmt die Menge der Keys eines Types. Es ist am nützlichsten als ConstraintK extends keyof T.typeofan einer Type-Position wandelt einen Wert in einen Type um. Wenn es mitas constkombiniert wird, hilft es, dass Arrays und konstante Objekte zur einzigen Quelle der Wahrheit werden.- Indexed access type
T[K]nimmt den Type einer Eigenschaft, eines Array-Elements ([number]) oder einer verschachtelten Struktur.
Wenn diese kombiniert werden, kannst du generische Funktionen und einen Event Bus schreiben, bei dem der Compiler fehlerhafte Keys oder falsche Payloads bereits beim Kompilieren erkennt. Du musst auch dieselbe Liste nicht an mehreren Orten wiederholen. Merke dir, strict zu aktivieren, tsc oder vitest --typecheck in der CI zu laufen, und nur lange Indexketten zu verwenden, wenn der Type wirklich voneinander abhängig ist.