keyof, typeof et indexed access types dans TypeScript
1. Aperçu général
Lorsque la codebase grandit, vous finirez par rencontrer des cas où un même ensemble de clés, par exemple les états d’une commande, apparaît à trois ou quatre endroits: dans un objet, dans un type union, dans un paramètre de fonction. Modifier un endroit en oubliant un autre entraîne un bug.
TypeScript possède trois outils pour déclarer une seule fois et en déduire le reste. keyof récupère l’ensemble des clés d’un type. typeof obtient le type à partir d’une valeur. Le type d’accès par index (T["key"]) obtient le type d’une propriété.
À la fin de cet article, vous saurez comment combiner ces trois outils pour écrire des fonctions génériques sécurisées et construire un event bus avec un typage strict. Vous connaîtrez aussi les erreurs courantes en les utilisant.
2. Préparation
L’article utilise TypeScript 5.x et Vitest pour écrire des tests. Vous pouvez l’installer comme suit:
npm install --save-dev typescript vitest
Vous devriez activer strict dans tsconfig.json. Sinon, de nombreuses erreurs concernant undefined ne seront pas signalées:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true
}
}
3. L’opérateur keyof
3.1. Utilisation basique
keyof T retourne une union contenant les noms de toutes les propriétés de T:
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"; // Erreur
La dernière ligne ne compile pas:
error TS2322: Type '"price"' is not assignable to type 'keyof Order'.
Vous n’avez pas besoin de taper manuellement l’union "id" | "total" | .... Quand vous ajoutez une nouvelle propriété à Order, OrderKey se met à jour automatiquement.
3.2. keyof avec index signature
Si le type utilise une index signature, le résultat peut vous surprendre:
type PriceTable = { [sku: string]: number };
type PriceKey = keyof PriceTable; // string | number
type ByIndex = { [index: number]: string };
type IndexKey = keyof ByIndex; // number
PriceKey est string | number et non pas seulement string. La raison est que en JavaScript, obj[1] et obj["1"] accèdent à la même propriété. C’est pourquoi TypeScript accepte les deux types de clés.
3.3. keyof dans les generics
keyof fonctionne mieux quand il est utilisé avec une contrainte de generic:
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"); // Erreur de compilation
Cette fonction fait plusieurs choses:
K extends keyof Tlimite leskey, donc il n’accepte que les noms de propriétés réelles deobj.- Le type de retour
T[K]est un type d’accès par index, expliqué à la section 5. Grâce à lui, chaque appel de la fonction reçoit le type correct de la propriété correspondante. - Un appel avec
"total"reçoitnumber. Un appel avec"status"reçoit l’union de littéraux. Pas deanyici.
4. L’opérateur typeof dans un contexte de type
JavaScript a naturellement typeof qui s’exécute à l’exécution et retourne une chaîne comme "string" ou "object". TypeScript ajoute un deuxième sens: quand il est placé dans une position de type, typeof x vous donne le type de la variable x.
4.1. Obtenir le type à partir d’une valeur
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" }));
Résultat:
{ name: 'Trần Thị Bình', email: 'an@example.com', loyaltyPoints: 0 }
Ici, la valeur par défaut est la source unique de vérité. Vous ajoutez un champ à defaultCustomer et Customer aura aussi ce champ.
Avec une fonction, vous combinez typeof avec un type utilitaire intégré:
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 et typeof
Par défaut, TypeScript “élargit” les littéraux: "pending" devient string, les tableaux deviennent string[]. Ajouter as const conserve le littéral et marque comme readonly:
const ORDER_STATUSES = ["pending", "paid", "shipped", "cancelled"] as const;
type OrderStatusList = typeof ORDER_STATUSES;
// readonly ["pending", "paid", "shipped", "cancelled"]
Si vous omettez as const, typeof ORDER_STATUSES n’est que string[] et vous perdez toutes les informations littérales.
4.3. Combiner keyof typeof
C’est une façon très courante d’écrire quand vous avez un objet constante et voulez obtenir l’union de ses clés:
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
La façon de lire cela est de l’intérieur vers l’extérieur. typeof SHIPPING_FEES obtient le type de l’objet, puis keyof obtient les clés de ce type. Vous ne pouvez pas écrire keyof SHIPPING_FEES, car SHIPPING_FEES est une valeur et non un type.
5. Types d’accès par index
5.1. Obtenir le type d’une propriété
La syntaxe T["key"] ressemble à l’accès à une propriété, mais elle se fait au niveau du type:
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 de toutes les propriétés
Il y a trois points à noter:
- Une propriété optionnelle (
note?) retourne un type avecundefined. - Passer une clé union retourne l’union des types correspondants.
T[keyof T]est la façon courante d’obtenir le “type de chaque valeur” dans un objet.
5.2. Obtenir le type des éléments du tableau
Indexer avec number vous donne le type de l’élément:
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
Le tableau ORDER_STATUSES sert maintenant deux objectifs. À l’exécution, c’est une liste pour valider l’entrée de l’API. À la compilation, il génère un type union. Vous déclarez l’état une seule fois.
5.3. Accès imbriqué
Vous pouvez chaîner plusieurs fois l’index pour aller profondément dans la structure:
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 est pris directement de Invoice, donc vous n’avez pas besoin de déclarer un type séparé et de garder les deux côtés synchronisés.
6. Exemple pratique: un event bus avec typage strict
Maintenant, mettez ensemble les trois outils. L’objectif est un event bus où la charge utile de chaque événement est vérifiée pour le type correct:
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(`Commande ${p.orderId} payée par ${p.method}`));
bus.emit("paid", { orderId: "ORD-42", method: "card" });
// bus.emit("paid", { orderId: "ORD-42", reason: "x" }); // Erreur: 'method' manquante
// bus.emit("refunded", { orderId: "ORD-42" }); // Erreur: l'événement n'existe pas
Résultat:
Commande ORD-42 payée par card
De chaque partie fonctionne comme ceci:
keyof OrderEventslimite le nom de l’événement à trois valeurs"created" | "paid" | "cancelled".OrderEvents[K]lie la charge utile au bon événement. Dans le gestionnaire de"paid", le paramètrepamethodet nonreason.- Le type mappé
[K in keyof OrderEvents]crée un référentiel de gestionnaires, chaque événement ayant sa propre liste. - Pour ajouter un événement
refunded, vous modifiez seulementOrderEvents. Tout le reste se met à jour automatiquement.
7. Vérifier les types avec Vitest
Vitest a expectTypeOf pour vérifier les types. Vous le combinez avec @ts-expect-error pour vous assurer que le code incorrect sera bloqué:
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 et indexed access", () => {
const order: Order = { id: "ORD-1", total: 250_000, status: "paid" };
it("retourne la bonne valeur et le bon type", () => {
const total = getProperty(order, "total");
expect(total).toBe(250_000);
expectTypeOf(total).toEqualTypeOf<number>();
expectTypeOf(getProperty(order, "status")).toEqualTypeOf<"pending" | "paid">();
});
it("refuse la clé qui n'existe pas", () => {
// @ts-expect-error - 'price' n'est pas une clé d'Order
getProperty(order, "price");
});
it("event bus appelle le handler avec la bonne charge utile", () => {
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 });
});
});
Vous exécutez les tests avec deux commandes:
npx vitest run --typecheck
npx tsc --noEmit
Note:
expectTypeOfn’a d’effet que quand le type est vérifié, via--typecheckoutsc. À l’exécution, il fait presque rien. Si vous exécutez justevitest runnormalement, le test passe même si le type est incorrect.
Si quelqu’un supprime "price" de la liste d’erreurs, par exemple en ajoutant price à Order, alors @ts-expect-error signalera “Unused ‘@ts-expect-error’ directive”. C’est un bon signal: votre test de type protège le contrat de l’API.
8. Erreurs courantes
Utiliser une variable comme index de type. Avec const key = "total", écrire Order[key] signalera une erreur:
error TS2749: 'key' refers to a value, but is being used as a type here. Did you mean 'typeof key'?
La façon de la corriger est d’écrire Order[typeof key].
Appeler typeof sur une expression. typeof loadConfig() est une erreur de syntaxe. typeof accepte seulement un identificateur ou un accès à une propriété comme typeof config.db. Avec le résultat d’une fonction, vous utilisez ReturnType<typeof loadConfig>.
Penser que Object.keys retourne keyof T. En réalité, il retourne string[]:
for (const key of Object.keys(order)) {
// order[key]; // Erreur: 'string' ne peut pas indexer 'Order'
}
TypeScript le fait intentionnellement. Le type est “structurel”, donc à l’exécution l’objet peut contenir plus de propriétés que ce que le type déclare. Si vous êtes sûr des données, vous pouvez caster Object.keys(order) as (keyof Order)[]. Mais comprenez que vous engagez votre responsabilité à la place du compilateur.
Oublier as const. Sans cela, (typeof ORDER_STATUSES)[number] n’est que string, et tous les avantages du littéral union disparaissent sans qu’aucune erreur ne soit signalée.
Note: N’abusez pas. Les chaînes comme
Invoice["customer"]["address"]["city"]ont du sens quand vous voulez vraiment que le type dépende deInvoice. SiAddressest un concept indépendant, utilisé à plusieurs endroits, déclarez-le comme type nommé. Le code sera plus facile à lire et les messages d’erreur plus concis.
9. Conclusion
Les trois outils de l’article font trois choses différentes:
keyofrécupère l’ensemble des clés d’un type. C’est le plus utile quand on l’utilise comme contrainteK extends keyof T.typeofdans une position de type transforme une valeur en type. Quand il est accompagné deas const, il aide les tableaux et objets constants à devenir la source unique de vérité.- Le type d’accès par index
T[K]récupère le type d’une propriété, d’un élément de tableau ([number]) ou d’une structure imbriquée.
Quand ils sont combinés, vous pouvez écrire des fonctions génériques et un event bus où le compilateur capture les erreurs de clés incorrectes ou de charge utile incorrecte au moment de la compilation. Vous n’avez pas non plus à répéter la même liste à plusieurs endroits. N’oubliez pas d’activer strict, exécutez tsc ou vitest --typecheck dans la CI, et utilisez seulement des chaînes d’index longues quand le type dépend vraiment de l’un l’autre.