wie fp-ts das URI-Pattern nutzt, um eine Sprachlücke zu schließen
TypeScript erlaubt Generics über konkreten Typen, aber keine Generics über Typkonstruktoren selbst. Eine Funktion, die für jeden beliebigen Container-Typ wie Array, Option oder Promise gleichermaßen funktioniert, lässt sich deshalb nicht direkt schreiben. Über eine Technik namens Defunctionalization, bekannt als URI-Pattern aus fp-ts, lässt sich dieses fehlende Feature dennoch praktikabel simulieren.
Inhaltsverzeichnis
- 1. Was Higher Kinded Types sind und warum TypeScript sie nicht kennt
- 2. Das konkrete Problem: ein generischer Functor über Container-Typen
- 3. Simulation über Defunctionalization: das URI-Pattern
- 4. Praxisbeispiel: Ein generisches Functor-Interface bauen
- 5. HKT-Simulation für Option- und Either-artige Container
- 6. Grenzen der Simulation und wann sie sich nicht lohnt
- 7. Praktische Alternativen ohne volle HKT-Simulation
- 8. Wie fp-ts das URI-Pattern in der Praxis einsetzt
- 9. Direkte Generics vs. HKT-Simulation vs. Codegen im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was Higher Kinded Types sind und warum TypeScript sie nicht kennt
Ein normaler generischer Typ wie Array<T> ist über einen konkreten Typ T parametrisiert. Higher Kinded Types gehen einen Schritt weiter: Sie parametrisieren über einen Typkonstruktor selbst, also über etwas wie Array, Promise oder Option, ohne bereits festzulegen, welcher konkrete Typ darin steckt. In Sprachen wie Haskell oder Scala schreibt man dafür Signaturen wie Functor<F>, wobei F selbst ein Typkonstruktor mit einem Slot ist, etwa F<_>.
TypeScripts Typsystem erlaubt diese Art der Abstraktion nicht direkt. Ein generischer Parameter in TypeScript muss immer ein konkreter Typ sein, niemals ein weiterer generischer Typkonstruktor mit offenem Slot. Man kann also nicht schreiben function map<F, A, B>(fa: F<A>, f: (a: A) => B): F<B> und erwarten, dass F für Array, Promise oder eine eigene Option-Klasse gleichermaßen eingesetzt werden kann. Diese Einschränkung ist als "kein Support für Higher Kinded Types" bekannt und betrifft praktisch jedes strukturell typisierte Typsystem ohne explizite Kind-Polymorphie.
Trotzdem gibt es eine praktikable Lösung, die in der funktionalen TypeScript-Bibliothek fp-ts seit Jahren produktiv im Einsatz ist: die Simulation von Higher Kinded Types über eine Technik namens Defunctionalization, umgangssprachlich das URI-Pattern genannt. Dieser Artikel erklärt das Problem im Detail, zeigt die Simulation Schritt für Schritt und benennt klar, wo die Grenzen liegen.
2. Das konkrete Problem: ein generischer Functor über Container-Typen
Um das Problem greifbar zu machen, betrachten wir eine map-Funktion, die für Array<A>, Option<A> und Either<E, A> strukturell identisch funktioniert: Sie nimmt einen Container und eine Transformationsfunktion entgegen und wendet die Funktion auf den enthaltenen Wert an, ohne den Container selbst zu verändern. Diese Operation heißt in der funktionalen Programmierung Functor, und jede dieser drei Implementierungen wäre für sich genommen trivial.
Das eigentliche Ziel ist aber eine einzige generische Signatur, die für beliebige Container-Typen funktioniert, ohne für jeden Container eine eigene map-Funktion neu zu implementieren. Genau das erfordert Higher Kinded Types, weil der Container-Typ selbst zum generischen Parameter werden muss. Ohne diese Abstraktion bleibt nur die Wahl zwischen Code-Duplizierung für jeden Container-Typ oder dem Verzicht auf Typsicherheit über any.
// Three structurally identical implementations, no shared abstraction
function mapArray<A, B>(fa: A[], f: (a: A) => B): B[] {
return fa.map(f);
}
function mapOption<A, B>(fa: A | undefined, f: (a: A) => B): B | undefined {
return fa === undefined ? undefined : f(fa);
}
// The goal: a single generic "map" that works for ANY container type F
// function map<F, A, B>(fa: F<A>, f: (a: A) => B): F<B>
// This is NOT valid TypeScript — F cannot be a type constructor parameter
3. Simulation über Defunctionalization: das URI-Pattern
Die Lösung, die fp-ts und ähnliche Bibliotheken einsetzen, heißt Defunctionalization: Statt den Typkonstruktor direkt als generischen Parameter zu übergeben, wird er durch einen eindeutigen String-Literal-Typ, die sogenannte URI, repräsentiert. Eine globale Registry, typischerweise ein Interface namens URItoKind<A>, ordnet jeder URI den tatsächlichen, mit A parametrisierten Typ zu. Eine generische Funktion arbeitet dann nicht mit dem Typkonstruktor selbst, sondern mit der URI als Platzhalter und schlägt den echten Typ über die Registry nach.
Diese Indirektion ist der Kern der Higher Kinded Types Simulation: Anstatt F<A> zu schreiben, schreibt man Kind<F, A>, wobei Kind ein Lookup-Typ ist, der die URI F in der Registry nachschlägt und den passenden konkreten Typ zurückgibt. Neue Container-Typen werden unterstützt, indem man die Registry per Declaration Merging um einen neuen Eintrag erweitert, ohne bestehenden Code anzufassen.
// The registry: maps a URI (string literal) to the actual generic type
interface URItoKind<A> {
Array: A[];
Option: A | undefined;
// new container types are added here via declaration merging
}
type URIS = keyof URItoKind<unknown>;
// "Kind" performs the lookup: URI + type parameter -> concrete type
type Kind<URI extends URIS, A> = URItoKind<A>[URI];
// Now map() can be written generically over the URI, not over F directly
function map<URI extends URIS, A, B>(
uri: URI,
fa: Kind<URI, A>,
f: (a: A) => B
): Kind<URI, B> {
// implementation dispatches based on uri at runtime
throw new Error("dispatch implementation omitted for brevity");
}
4. Praxisbeispiel: Ein generisches Functor-Interface bauen
Mit dem Kind-Mechanismus aus dem vorherigen Abschnitt lässt sich ein echtes, wiederverwendbares Functor-Interface definieren, das für jede URI eine eigene, aber typsicher signaturkompatible Implementierung fordert. Jede konkrete Instanz dieses Interfaces, etwa für Array oder Option, implementiert map mit der jeweils passenden Laufzeitlogik, während die Typsignatur über Kind<URI, A> für alle Instanzen identisch bleibt.
Der praktische Vorteil zeigt sich, sobald man generische Hilfsfunktionen schreibt, die selbst nur eine Functor-Instanz als Parameter entgegennehmen, etwa eine Funktion, die zwei Transformationen nacheinander anwendet. Eine solche Hilfsfunktion muss nicht wissen, ob sie mit Array, Option oder einem selbst geschriebenen Result-Typ arbeitet, solange eine passende Functor-Instanz existiert.
interface Functor<URI extends URIS> {
map<A, B>(fa: Kind<URI, A>, f: (a: A) => B): Kind<URI, B>;
}
const arrayFunctor: Functor<"Array"> = {
map: (fa, f) => fa.map(f),
};
const optionFunctor: Functor<"Option"> = {
map: (fa, f) => (fa === undefined ? undefined : f(fa)),
};
// A generic helper that works with ANY Functor instance
function mapTwice<URI extends URIS, A>(
F: Functor<URI>,
fa: Kind<URI, A>,
f: (a: A) => A
): Kind<URI, A> {
return F.map(F.map(fa, f), f);
}
mapTwice(arrayFunctor, [1, 2, 3], (x) => x * 2); // [4, 8, 12]
5. HKT-Simulation für Option- und Either-artige Container
Container mit mehr als einem Typparameter, etwa Either<E, A> mit einem Fehler- und einem Erfolgstyp, erfordern eine leichte Erweiterung des Musters, weil die Registry dann über zwei Parameter statt nur über A parametrisiert werden muss. fp-ts löst das mit einer eigenen Namenskonvention, URItoKind2<E, A> für zweistellige Typkonstruktoren, zusätzlich zu URItoKind<A> für einstellige. Diese Aufteilung nach Anzahl der Typparameter ist notwendig, weil TypeScript Interfaces nicht über eine variable Anzahl generischer Parameter hinweg vereinheitlichen kann.
In der Praxis bedeutet das, dass eine vollständige Higher Kinded Types Simulation mehrere parallele Registries braucht, eine pro Anzahl der Typparameter, was die Technik zwar mächtig, aber auch merklich komplexer macht als eine einfache generische Funktion. Für die allermeisten Anwendungsfälle in Geschäftsanwendungen reicht die einstellige Variante aus Abschnitt 3 und 4 bereits aus.
// Two-parameter container types need a separate registry keyed by arity
interface URItoKind2<E, A> {
Either: { _tag: "Left"; left: E } | { _tag: "Right"; right: A };
}
type URIS2 = keyof URItoKind2<unknown, unknown>;
type Kind2<URI extends URIS2, E, A> = URItoKind2<E, A>[URI];
function mapEither<E, A, B>(
fa: Kind2<"Either", E, A>,
f: (a: A) => B
): Kind2<"Either", E, B> {
return fa._tag === "Left" ? fa : { _tag: "Right", right: f(fa.right) };
}
6. Grenzen der Simulation und wann sie sich nicht lohnt
Die Defunctionalization-Technik hat reale Grenzen. Erstens erzeugt sie eine indirekte Typebene, die für Entwickler ohne Erfahrung mit funktionaler Programmierung schwer zu lesen ist, weil Kind<URI, A> keine unmittelbare, selbsterklärende Beziehung zum eigentlichen Typ zeigt. Zweitens skaliert die Registry nicht beliebig: jede Anzahl von Typparametern braucht eine eigene Registry-Interface-Familie, was bei drei- oder vierstelligen Typkonstruktoren schnell unübersichtlich wird.
Drittens, und das ist der wichtigste praktische Punkt: Die meisten Teams, die keine dedizierte funktionale Programmierbibliothek wie fp-ts einsetzen, profitieren selten davon, eine eigene Higher Kinded Types Simulation von Grund auf zu bauen. Der Wartungsaufwand für die Registry und die Kind-Lookups übersteigt in gewöhnlichen Geschäftsanwendungen häufig den Nutzen gegenüber einfacheren Alternativen wie separaten, aber leicht duplizierten Funktionen pro Container-Typ.
7. Praktische Alternativen ohne volle HKT-Simulation
Für viele Anwendungsfälle reicht ein deutlich einfacherer Ansatz: eine generische Funktion, die direkt über den konkreten Elementtyp statt über den Container-Typ parametrisiert ist, kombiniert mit wenigen, bewusst duplizierten map-Implementierungen pro Container. Dieser Ansatz verzichtet auf die abstrakte Vereinheitlichung, bleibt aber für die meisten Teams deutlich verständlicher und wartbarer als eine vollständige HKT-Simulation.
Eine weitere Alternative ist Codegenerierung: Statt Higher Kinded Types zur Compile-Zeit zu simulieren, generiert ein Build-Schritt für jeden konkreten Container-Typ eine eigene, monomorphe Implementierung aus einer gemeinsamen Vorlage. Das verschiebt die Abstraktion aus dem Typsystem in den Build-Prozess und vermeidet die Lesbarkeitsprobleme der URI-Pattern-Technik, kostet aber zusätzliche Build-Komplexität.
8. Wie fp-ts das URI-Pattern in der Praxis einsetzt
fp-ts nutzt das in den vorherigen Abschnitten gezeigte URI-Pattern konsequent für seine gesamte Typklassen-Hierarchie, von Functor über Applicative bis Monad. Jeder Datentyp der Bibliothek, etwa Option, Either oder Task, registriert sich einmalig in der globalen URItoKind-Registry und implementiert dann die passenden Typklassen-Instanzen. Nutzer der Bibliothek schreiben generischen Code gegen diese Typklassen, ohne selbst eine eigene HKT-Simulation bauen zu müssen.
Der praktische Nutzen für Anwender liegt darin, dass generische Kombinatoren wie pipe, flow oder sequenceT einmal geschrieben werden und für jeden registrierten Container-Typ funktionieren, ohne Code-Duplizierung. Wer die Mechanik aus diesem Artikel versteht, kann fp-ts-Typfehler deutlich schneller einordnen, weil viele auf den ersten Blick kryptischen Fehlermeldungen direkt aus der Kind-Lookup-Indirektion entstehen.
9. Direkte Generics vs. HKT-Simulation vs. Codegen im Vergleich
Die Wahl zwischen den drei vorgestellten Ansätzen hängt stark von Teamgröße, funktionaler Erfahrung und der tatsächlichen Anzahl unterschiedlicher Container-Typen ab. Die folgende Tabelle fasst die Trade-offs zusammen.
| Ansatz | Abstraktionsgrad | Lesbarkeit | Wann geeignet |
|---|---|---|---|
| Direkte, duplizierte Generics | Niedrig | Hoch | Zwei bis drei Container-Typen im Projekt |
| HKT-Simulation (URI-Pattern) | Hoch | Niedrig ohne FP-Erfahrung | Bibliotheken mit vielen Typklassen wie fp-ts |
| Codegenerierung | Mittel | Hoch pro generiertem Modul | Viele nahezu identische Container-Typen |
| fp-ts-Bibliothek einsetzen | Hoch, fertig | Erfordert Einarbeitung | Wenn funktionale Patterns bereits Teamstandard sind |
Für die meisten Projekte außerhalb dedizierter funktionaler Bibliotheken ist die eigene HKT-Simulation die falsche Wahl. Der praktische Rat lautet fast immer: entweder fp-ts direkt als Abhängigkeit nutzen, wenn der Bedarf nach generischen Typklassen real ist, oder bei zwei bis drei Container-Typen auf die einfache, duplizierte Variante setzen, statt eine eigene URI-Pattern-Infrastruktur zu pflegen.
Mironsoft
TypeScript-Architektur, Typsystem-Beratung und Refactoring
Funktionale Patterns, die euer Team wirklich versteht?
Wir bewerten, ob eine HKT-Simulation, fp-ts oder ein pragmatischerer Ansatz zu eurem Projekt passt, und bauen die passende Typabstraktion, die euer Team langfristig warten kann.
Architektur-Review
Bewertung, ob funktionale Typabstraktionen euren Projektaufwand rechtfertigen
fp-ts-Einführung
Schrittweise Integration mit Schulung für bestehende TypeScript-Teams
Type-Level Coaching
Workshops zu Generics, Typklassen und fortgeschrittenem Typsystem-Design
10. Zusammenfassung
Higher Kinded Types beschreiben Generics über Typkonstruktoren statt über konkrete Typen, ein Feature, das TypeScript nicht nativ unterstützt. Die Defunctionalization-Technik, bekannt als URI-Pattern aus fp-ts, simuliert dieses fehlende Feature über eine globale Registry, die String-Literal-URIs auf konkrete, parametrisierte Typen abbildet, sowie einen Kind-Lookup-Typ, der diese Zuordnung zur Compile-Zeit auflöst.
Die Simulation ermöglicht echte, wiederverwendbare Typklassen wie Functor oder Monad, hat aber reale Kosten: geringere Lesbarkeit ohne funktionale Vorerfahrung und getrennte Registries pro Anzahl der Typparameter. Für die meisten Geschäftsanwendungen außerhalb dedizierter funktionaler Bibliotheken lohnt sich eine eigene Higher Kinded Types Simulation selten, entweder die direkte fp-ts-Nutzung oder eine einfachere, bewusst duplizierte Lösung ist meist die pragmatischere Wahl.
Higher Kinded Types Simulation — Das Wichtigste auf einen Blick
Grundproblem
TypeScript erlaubt keine Generics über Typkonstruktoren wie Array oder Option, nur über konkrete Typen selbst.
URI-Pattern
Eine globale Registry ordnet String-Literal-URIs konkreten, parametrisierten Typen zu. Ein Kind-Lookup-Typ löst diese Zuordnung auf.
Grenzen
Getrennte Registries pro Anzahl der Typparameter, geringere Lesbarkeit ohne funktionale Erfahrung im Team.
Praktische Empfehlung
fp-ts direkt nutzen bei echtem Bedarf, sonst einfache duplizierte Generics statt eigener HKT-Infrastruktur.