Guerrilla-Typen in TypeScript
Renegade-Type-Design
Guerrilla-Typen in TypeScript
In diesem Artikel erkunden wir drei faszinierende (möglicherweise schreckliche?) Techniken für das Type-Design!
Das Hauptziel sind konsistente und vorhersagbare Model/Entity/Class-Interfaces.
- Ansätze zum Entwerfen von Typen
- Technik #1: Warum nicht alles
- Technik #2: Mix-ins
- Technik #3: Organisieren mit Namespaces
- Zusammenfassung
Ansätze zum Entwerfen von Typen
Du hast wahrscheinlich bereits unterschiedliche Muster rund um „Type-Implementierungen” gesehen oder geschrieben. Besonders beim Konsumieren von Daten aus APIs Dritter.
Hinweis: Ich ignoriere bewusst „traditionelle” Prozesse wie das Erstellen von Entity-Relationship-Diagrammen (ERD) oder Vererbungshierarchien der objektorientierten Programmierung (OOP). Hier bauen wir Typen, die halbstrukturierte API-Daten repräsentieren.
Lass uns zwei übergeordnete Ansätze erkunden: Einzelnes großes Objekt (Top-down) vs. Mehrere benannte Typen (Bottom-up.)
Einzelnes großes Objekt
Priorisiert Explizitheit gegenüber Wiederverwendbarkeit & DRY-Prinzip.
Bonus: Die IDE/Dev Experience ist hervorragend, da Tooltips eine vollständigere Vorschau bieten – ohne Aufwand.
interface ProductDetails { name: string; seller: { name: string }; availability: Array<{ warehouseId: string; quantity: number }>; reviews: Array<{ authorId: number; stars: number }>;}Da wir explizite Lesbarkeit priorisieren, ist es in Ordnung, einige Wiederholungen zuzulassen (in vernünftigem Rahmen.) Wenn sich Gruppen von Eigenschaften sehr oft wiederholen, kannst du die wiederholten Felder gerne in einen benannten Typ extrahieren.
Mehrere benannte Typen
Priorisiert Wiederverwendbarkeit & DRY-Prinzip.
Dieser Ansatz ist wahrscheinlich der mit Abstand bevorzugte.
interface ProductDetails { name: string; seller: Seller; reviews: Reviews[]; availability: Availability[];}interface Seller { name: string; }interface Availability { warehouseId: string; quantity: number; }interface Reviews { authorId: number; stars: number; }Insgesamt ist dieser Ansatz großartig. Aber er ist nicht ohne Nachteile.
- Lesbarkeit ist anfangs exzellent; sie kann jedoch leiden, wenn die Größe und Anzahl der Typen wächst.
- Unerbittlich DRY, aber zu welchem Preis? (Mehr dazu später.)
- Die Developer Experience leidet, da Tooltips weniger informativ sind.
⚠️ Seit (ca.) TypeScript v3 kürzt der Language Server Tooltips und lässt verschachtelte Eigenschaften weg. 💡 Es gibt Tricks, um die Sache etwas zu verbessern. Halte
Cmd oder Ctrlgedrückt, dann fahre mit der Maus über verschiedene Typnamen – du solltest mindestens eine zusätzliche „Ebene” von Eigenschaften im Tooltip sehen.
Warum müssen wir uns zwischen diesen beiden Ansätzen entscheiden? (Riesiger Typ vs. benannte Sub-Typen.)
Technik #1: Warum nicht alles
Können wir beides haben?
- Klarheit der „Big-Picture”-Typen?
- Plus benannte Sub-Typen?
- Ohne Duplizierung?
✅ JA! 🎉
export interface ProductDetails { name: string; seller: { name: string }; reviews: Array<{ authorId: number; stars: number }>; availability: Array<{ warehouseId: string; quantity: number }>;}export type Seller = ProductDetails["seller"];export type Review = ProductDetails["reviews"][number];export type Availability = ProductDetails["availability"][number];- Erstelle große „primäre” strukturierte Typen.
- Exportiere Sub-Typen, die vom primären Typ abgeleitet sind.
Dieser Ansatz glänzt besonders in Systemen, in denen „hochrangige” Objekte von Dokumentation an einer Stelle profitieren. Außerdem unterstützt diese Technik die Wiederverwendung zwischen vielen Anwendungsfällen: Models, Services, Query-Ergebnisse usw.
Technik #2: Mix-ins
Diese Strategie dreht sich darum, die richtigen Felder mit den richtigen Namen zusammenzusetzen, um einzelne logische Objekte zu repräsentieren. Das Ziel ist es, mehrere Anwendungsfälle effizient mit TypeScript-Utilities und Type-Unions abzudecken.
Dieser Ansatz unterscheidet sich von traditioneller OOP-Vererbung & Hierarchien, die darauf abzielen, Objektschichten in eng verbundene Taxonomien zu gliedern. Der Mix-in-Ansatz zielt auf flache und lose verwandte Typen ab, die zusammengehörige Felder gruppieren und dabei Duplizierung reduzieren.
Mix-in-Beispiele
interface TodoModel { text: string; complete: boolean;}interface InstanceMixin { id: number;}/** TodoDraft represents Form state, possibly all undefined */export type TodoDraft = Partial<TodoModel>;/** Todo represents a Todo instance record from the database */export type Todo = TodoModel & InstanceMixin;Beispiel User
interface User { id: number; name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}Lass uns den User vor und nach dem Speichern in der Datenbank repräsentieren.
// Core User fields (say for a <form>)interface UserBase { name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}// Fields from the databaseinterface InstanceMixin { id: number; createdAt: Date; updatedAt: Date;}// A User **instance** - with all fieldstype UserInstance = InstanceMixin & UserBase;Jetzt können wir die exakten Felder formen, die wir brauchen (wie password für Create/Update, aber nicht enthalten in Queries von UserInstance).
interface UserBase { name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}interface InstanceMixin { id: number; createdAt: Date; updatedAt: Date;}/** User payload for signup, including `password` field */export type UserPayload = UserBase & { password: string };/** Represents User type returned from server. */export type UserInstance = UserBase & InstanceMixin;- „Ist das gute Praxis?”
- „Sollte ich das ausprobieren?”
Keine Ahnung. Lass uns weitermachen!
Technik #3: Organisieren mit Namespaces
Hier deklarieren wir einen ModelMixins-Namespace. Das bietet etwas Organisation und ein klareres Wiederverwendungsmuster.
Standardisierte Shapes
createdAt&updatedAtexistieren zusammen.id, nichtIDoder_id.
// `src/types/mixins.d.ts`namespace ModelMixins { interface Identity { id: number; } interface Timestamp { createdAt: Date; updatedAt: Date; } type Instance = ModelMixins.Identity & ModelMixins.Timestamp; interface HashedPassword { passwordHash: string; } interface InputPassword { password: string; }}Verwenden von Type Unions
// `src/types/user.d.ts`export interface UserBase { name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}// Single `User` type, using Type Union to dynamically// represent the pre- & post-creation states.export type User = | (UserBase & ModelMixins.Instance & ModelMixins.HashedPassword) | (UserBase & ModelMixins.InputPassword);Falls gewünscht, kannst du auch einzelne benannte Typen exportieren:
/** User payload for signup, including `password` field */export type UserPayload = UserBase & ModelMixins.Instance & ModelMixins.HashedPassword;/** Represents User type returned from server. */export type UserInstance = UserBase & ModelMixins.InputPassword;Praxisnahe Anwendung
Hier ist eine upsert()-Funktion, die den in-Operator verwendet, um zwischen UserInstance- und UserPayload-Typen zu unterscheiden.
function upsert(user: User) { if ("id" in user) { // TypeScript knows `user` here has fields from Instance (id, createdAt, etc) return updateUser(user.id, user); } else { // TypeScript knows this must be the `UserBase & ModelMixins.InputPassword` version of user. return createUser(user); }}Zusammenfassung
Wir haben drei Techniken und einige damit verbundene unterstützende Ideen behandelt.
Du fragst dich vielleicht: Sind das gute Patterns? Sollte ich einige dieser Ideen übernehmen?
Ressourcen
- TypeScript tips for legacy projects: Type only you need
- Matt Pocock’s Excellent new book
- Total TypeScript Tips