Jade/JDL Typsystem — Persönliche Notizen¶
Zum Nachschlagen. Kein Teil der Specs. Stand: März 2026.
Inhaltsverzeichnis¶
- Das Effect System
- Row Polymorphismus
- Existential Types
- Die offenen Fragen im Typsystem
- TypeNode in D — die interne Repräsentation
- Interning
- MetaRecord
- reified — wann und warum
- Symboltabelle und Namespaces
- Unifikationsvariablen — Ausblick
1. Das Effect System¶
Was ist Effect[R, E, D]?¶
Effect[R, E, D] ist eine Beschreibung einer Berechnung — keine sofortige Ausführung.
// Das hier führt NICHTS aus:
def fetchUser(id: UserId) -> Effect[User, UserError, UserDb] =
UserDb.getUserById(id)
// Das hier führt aus (CallGraph ist die Engine):
CallGraph app(req: HttpRequest) -> Result[HttpResponse, AppError] {
requires: [UserDb]
env: { UserDb: PostgresUserDb { pool } }
pub declares: useractive[bool] = true
pro declares: canActivate[bool]
= if #useractive == true then false else true
Call[CallHandleRequest] <{}> {
handleRequest(req)
}
CatchErrors[CallHandleRequest] {}
RetryPolicy {}
CachePolicy {}
} :> Serializable :> Sendable :> Inspectable :> !Reifieable
Die drei Typparameter bedeuten:
- R — Erfolgstyp (was rauskommt wenn alles gut geht)
- E — Fehlertyp (was rauskommt wenn etwas schiefgeht)
- D — Dependencies (welche Services benötigt werden)
Konzeptuell identisch mit ZIO in Scala — nur mit vertauschter Reihenfolge (ZIO[R, E, A]).
Zwei gleichberechtigte Stile¶
JDL kennt keinen bevorzugten Stil. Beide sind vollwertig:
// Imperativer Stil — Service als Parameter, sofortige Ausführung:
def fetchUser(db: UserDb, id: UserId) -> Result[User, UserError] =
db.getUserById(id)
// Effect-Stil — lazy Beschreibung, Service im Typ D:
def fetchUser(id: UserId) -> Effect[User, UserError, UserDb] =
UserDb.getUserById(id)
Der Funktionskörper ist identisch. Der Compiler entscheidet anhand des Rückgabetyps welches Modell gilt.
Komposition mit =?¶
Effects sind über =? komponierbar:
def authenticate(req: HttpRequest) -> Effect[AuthToken, Unauthorized, AuthService]
def persist(token: AuthToken) -> Effect[User, DbError, UserDb]
def handleRequest(req: HttpRequest) -> Effect[User, AppError, [AuthService, UserDb]] =
val token =? authenticate(req)
val user =? persist(token)
Effect.ok(user)
Das Problem mit D bei Higher-Order Funktionen¶
Wenn eine Funktion eine andere Effect-Funktion als Parameter nimmt:
def pipeline[D](
f: HttpRequest -> Effect[Token, AuthError, D]
) -> Effect[Response, AppError, D + [UserDb]] =
...
Hier taucht D + [UserDb] auf — eine Set-Union auf Typ-Ebene. Das
ist eine der offenen Fragen (siehe Abschnitt 4).
2. Row Polymorphismus¶
Das Grundprinzip¶
Row Polymorphismus ist nicht Any. Der Unterschied ist fundamental:
Any |
Row Variable r |
|
|---|---|---|
| Typinfo zur Compile-Zeit | weg | erhalten |
| Laufzeit-Cast nötig | ja | nein |
| Compiler kann prüfen | nein | ja |
| Komposierbar | nein | ja |
Any sagt: "Ich weiß es nicht und ich gebe auf."
Row Polymorphismus sagt: "Ich weiß nicht alles, aber was ich weiß ist garantiert — und der Rest bleibt nachvollziehbar."
Wie es funktioniert¶
// Ohne Row Polymorphismus — eine Funktion pro Typ:
def logUser(x: User) -> () = Console.print(x.name)
def logProduct(x: Product) -> () = Console.print(x.name)
// Mit Row Polymorphismus — eine Funktion für alle:
def log[r](x: { name: str | r }) -> () =
Console.print(x.name)
Das | r ist die Row-Variable — sie steht für "alles andere was noch im Record ist".
Warum der Rückgabetyp wichtig ist¶
val alice = User { name: "Alice", email: "a@b.com", age: 30 }
// Wenn die Funktion r durchreicht:
def withPrefix[r](x: { name: str | r }, prefix: str) -> { name: str | r } =
{ ...x, name: f"{prefix}: {x.name}" }
val alice2 = withPrefix(alice, "Admin")
// alice2 ist immer noch ein User!
// alice2.email ist noch da — r wurde durchgereicht
// Kein Informationsverlust
Das ist der entscheidende Vorteil: der konkrete Typ bleibt durch Transformationen hindurch erhalten.
Verbindung zu JDL¶
Dein Meta-Record System arbeitet bereits implizit mit Row Polymorphismus:
typefn Share(p: SharePolicy) = <{ share: p }>
type DbConnection: struct { handle: HandleId }
:> Share(Send) // merged <{ share: Send }> ins bestehende Meta-Record
:> Drop(Custom) // merged <{ drop: Custom }> — andere Felder bleiben
Der Compiler erweitert das Meta-Record ohne den Rest zu zerstören — das ist strukturell dasselbe Prinzip.
Was noch fehlt¶
Für den Higher-Order Effect-Fall (D + [UserDb]) bräuchte man
Row Polymorphismus explizit in Funktionssignaturen — plus einen
ServiceSet-Constraint. Das ist eine offene Designfrage.
3. Existential Types¶
"Es gibt einen Typ der X implementiert, aber ich sage nicht welcher"¶
Das klingt kryptisch, ist aber sehr praktisch. Du hast es in JDL bereits:
service UserDb {
def getUserById(id: str) -> Result[User, UserError]
}
provide UserDb for PostgresUserDb { ... }
provide UserDb for InMemoryUserDb { ... }
val db: UserDb = getDatabase()
// "Da ist ein UserDb — welcher konkret? Keine Ahnung."
Das ist ein Existential Type. Der Unterschied zu Generics:
// Generic — der AUFRUFER bestimmt den konkreten Typ:
def withDb[T](db: T) where T: UserDb -> ()
// Existential — die FUNKTION versteckt den konkreten Typ:
def getDb() -> some UserDb
Bei Existential Types weiß der Aufrufer: "Da ist ein UserDb." Welche konkrete Implementierung steckt dahinter, ist absichtlich verborgen.
4. Die offenen Fragen im Typsystem¶
Diese Fragen wurden bewusst offen gelassen — sie brauchen mehr theoretisches Fundament bevor man sich festlegt:
4.1 Wie weit geht die Typinferenz?¶
Option A — Lokal: val x = 42 → x: i32 (nur Literale)
Option B — HM: val x = f(y) → vollständig inferiert
Option C — Bidirekt.: auch von Rückgabetyp nach innen
4.2 Nominal oder Strukturell?¶
Nominal: User ≠ { name: str, email: str } — Name zählt
Strukturell: User = { name: str, email: str } — Struktur zählt
Gemischt: Nominal für benannte Typen, strukturell für Records
4.3 ServiceSet und D-Union¶
Damit D + [UserDb] funktioniert braucht die Type Engine:
- ServiceSet als neuen Kind-Constraint
- Union als intrinsic typefn
- Sugar + für Set-Union auf ServiceSets
Das ist aufschiebbar — für Phase 1 kann D manuell deklariert werden.
Lektüreempfehlung für diese Punkte: - Types and Programming Languages (TAPL) — Pierce — Kapitel zu Type Operators und Kinds - Koka-Sprache von Daan Leijen — löst das Effect-Problem mit Row Polymorphism
5. TypeNode in D — die interne Repräsentation¶
Das Grundproblem¶
Die Type Engine muss JDL-Typen als D-Datenstrukturen repräsentieren. Ein JDL-Typ kann sein:
i32 // Primitiv
str // Primitiv mit Handle-Semantik
User // Named Struct
Option[T] // Generic — ungebunden
Option[User] // Applied — Generic mit Argument
Effect[User, UserError, UserDb] // Applied mit mehreren Argumenten
T // Typparameter — ungebunden
UserPhase.Active // Enum Variant
str | i32 | User // Union
(str, i32) // Tuple
str -> bool // Funktionstyp
<{ share: Local }> // Meta-Record Typ
Der TypeKind Enum¶
enum TypeKind {
Primitive, // i32, str, bool, u8, f64, ...
Named, // User, UserError, DbConnection, ...
Param, // T, U, K, V — ungebundene Typparameter
Applied, // Option[User], Effect[R,E,D], Map[K,V], ...
Union, // str | i32 | User
Tuple, // (str, i32, bool)
Fn, // str -> bool, (i32, str) -> User
Meta, // <{ share: Local, own: Unique }>
}
Die TypeNode Struktur¶
struct TypeNode {
TypeKind kind;
MetaRecord meta; // immer dabei — auch bei Primitiven
union {
PrimitiveData prim; // welches Primitiv (i32, str, ...)
NamedData named; // Symbol (vollqualifizierter Name)
ParamData param; // Typparameter (Owner + Name)
AppliedData applied; // base TypeId + args[]
UnionData union_; // members[]
TupleData tuple; // elements[]
FnData fn; // params[] + returnType
}
}
Applied — der wichtigste Fall¶
Option[User] ist nicht "Option mit gesetztem T" — sondern ein
neuer Knoten der sagt: "Wende Option auf User an."
struct AppliedData {
TypeId base; // TypeId von Option, Effect, Map, ...
TypeId[] args; // [TypeId(User)] — Slice in der Arena
}
Das folgt dem Prinzip der Curry-Howard style application — ein Typ wird auf einen anderen Typ angewandt, genau wie eine Funktion auf ein Argument.
Option → Typkonstruktor, erwartet 1 Argument
Option[User] → Option angewandt auf User → neuer TypeNode
6. Interning¶
Was Interning bedeutet¶
Interning bedeutet: jeder einzigartige Typ existiert genau einmal
im Speicher. Alle Referenzen darauf sind nur eine ID (uint).
Die Intern-Tabelle:
Index (TypeId) → TypeNode
0 → i32
1 → str
2 → User
3 → UserError
4 → UserDb
42 → Effect[User, UserError, UserDb]
Warum das wichtig ist¶
Ohne Interning — Typvergleich ist O(n):
bool typesEqual(TypeNode a, TypeNode b) {
if (a.kind != b.kind) return false;
if (a.kind == TypeKind.Applied) {
// Rekursiv durch den ganzen Typbaum — O(n)
return typesEqual(a.applied.base, b.applied.base)
&& allEqual(a.applied.args, b.applied.args);
}
// ...
}
Mit Interning — Typvergleich ist O(1):
In einer Type Engine wird Typvergleich tausende Male aufgerufen.
Bei tiefen Typen wie Effect[Result[User, UserError], AppError, [UserDb, LogService]]
ist der Unterschied enorm.
@nogc Kompatibilität¶
Interning funktioniert perfekt mit @nogc weil alle TypeNodes in
einer Arena leben:
Arena!TypeNode typeArena;
TypeId intern(TypeNode node) {
auto key = hash(node);
if (auto existing = hashTable.get(key))
return *existing;
// Neuer Eintrag — in Arena allozieren, nie freigeben
auto id = TypeId(cast(uint) typeArena.length);
typeArena.put(node);
hashTable.put(key, id);
return id;
}
Kein GC. Kein Heap. Alles in der Arena.
Der zweite @nogc Bonus¶
Interning eliminiert auch das Problem mit Refinements. Wenn :> einen
neuen Typ erzeugt:
...wird intern nur ein neuer TypeNode in die Arena geschrieben — die restlichen Typen bleiben unangetastet. Kein Allokations-Overhead, nur ein neuer Eintrag.
7. MetaRecord¶
Zwei Arten von Feldern¶
struct MetaRecord {
// System-Felder — explizit, typsicher, von Engine direkt gelesen:
SharePolicy share; // Local | Send | Sync
OwnPolicy own; // Unique | Shared | Weak
MemoryPolicy memory; // Value | Ref | Arena[A] | Pool[P]
DropPolicy drop; // Trivial | Custom
CreatePolicy create; // Trivial | Factory[F]
// User-Feld — ein einziger Slot für alles was User/Frameworks schreiben:
MetaEntry[] ext; // Slice in Arena — key/value Paare
}
struct MetaEntry {
TypeId key; // intern'd key — z.B. TypeId für "cached"
TypeId value; // intern'd value — z.B. TypeId für Ttl(300)
}
Warum diese Trennung?¶
System-Felder sind in D hart kodiert:
- Type Engine und Memory Engine lesen sie direkt — kein Lookup nötig
- Geschützt — User kann sie nicht überschreiben
- O(1) Zugriff: meta.share == SharePolicy.Local
- Neue System-Felder brauchen D-Code-Änderung — das ist Absicht
User-Feld (ext):
- User-definierte Refinements (Cached, Retry, MyCustomThing) landen hier
- Compiler ignoriert unbekannte Keys — Frameworks lesen sie
- Neue user-definierte Refinements brauchen keine D-Änderung
- Lookup ist O(n) — aber Meta-Records sind immer klein (3-6 Einträge)
Immutability¶
Das MetaRecord ist nach der Erstellung unveränderlich — zur Compile-Zeit UND zur Runtime:
// Zur Runtime lesen — OK (wenn Typ reified):
val info = TypeInfo.of[DbConnection]()
val policy = info.meta.share // lesbar
// Zur Runtime schreiben — NIEMALS:
info.meta.share = Send // COMPILERFEHLER
info.meta.ext["cached"] = ... // COMPILERFEHLER
Würde man zur Runtime schreiben können, wären Compiler-Garantien wie
share: Local bedeutungslos.
MetaRecord ist Teil der Typ-Identität¶
TypeId intern(TypeNode node) {
// Hash über Struktur UND Meta — beide bestimmen die Identität
auto key = hash(node.kind, node.structData, node.meta);
// ...
}
Zwei Typen mit identischer Struktur aber verschiedenem MetaRecord sind verschiedene TypeIds:
Das passt perfekt zu :> als Operator — er produziert einen neuen Typ.
8. reified — wann und warum¶
Die einfache Regel¶
Konkreter Typ → meta ist compile-time Konstante
direkt lesbar, kein Overhead, kein reified
Typparameter T → zur Compile-Zeit unbekannt
nur lesbar zur Runtime wenn T: reified
sonst: T ist opak — meta nicht zugreifbar
In Code¶
// Konkret — immer verfügbar, kein reified:
DbConnection.meta.share // compile-time Konstante, kein Overhead
// Generic ohne reified — meta nicht zugreifbar:
def inspect[T](x: T) -> str =
T.meta.share // COMPILERFEHLER — T ist opak
// Generic mit reified — meta zur Runtime lesbar:
def inspect[reified T](x: T) -> str =
T.meta.share // OK
Warum reified Overhead hat¶
Ohne reified ist T zur Runtime komplett verschwunden — reine
Compile-Zeit-Abstraktion. Der Compiler monomorphisiert: für jeden
konkreten Typ der T füllt wird eine separate Funktion erzeugt.
Mit reified muss die TypeInfo als echtes Objekt mitgeschleppt werden —
Laufzeit-Overhead, mehr Speicher.
Die praktische Konsequenz¶
reified ist nur relevant bei Generics deren Typ zur Runtime
unbekannt ist und introspektiert werden soll. In der Praxis ist das
selten — TypeInfo.of[T]() in match-Ausdrücken, Serialisierung,
Reflection-ähnliche Features.
9. Symboltabelle und Namespaces¶
Das Problem¶
Beide heißen "User" — kurze Namen allein reichen nicht zur Identifikation.
Qualified Namespaces als Lösung¶
Der vollqualifizierte Name ist die Identität:
Kein Konflikt, egal wie viele Module denselben kurzen Namen verwenden.
In D¶
struct Symbol {
TypeId[] namespace; // [auth] oder [billing, invoicing] — Slice in Arena
TypeId name; // intern'd "User"
}
struct NamedData {
Symbol symbol; // vollqualifiziert — eindeutig im gesamten System
}
Symbol wird selbst intern'd — auth.User existiert genau einmal.
Typparameter bekommen synthetische Namespaces¶
In der Symboltabelle:
Option.T → TypeId(99) // T aus Option
Map.K → TypeId(100) // K aus Map
Map.V → TypeId(101) // V aus Map — anderes T als Option.T!
struct ParamData {
Symbol owner; // wer hat diesen Parameter definiert (Option, Map, ...)
TypeId name; // intern'd "T", "K", "V"
}
T in Option[T] und T in Map[K, V] sind niemals dieselbe
TypeId — obwohl beide den Namen "T" tragen. Kein Shadowing-Problem,
kein Scope-Management zur Laufzeit.
10. Unifikationsvariablen — Ausblick¶
Das ist der nächste große Schritt — noch nicht implementiert, aber wichtig zu verstehen.
Das Problem mit Typinferenz und Interning¶
Interning funktioniert perfekt für bekannte Typen. Aber bei Typinferenz gibt es Typen die noch unbekannt sind:
val x = [] // Was ist der Elementtyp? Noch unklar.
val y = x // y hat denselben unbekannten Typ wie x
Der Compiler erzeugt eine Unifikationsvariable ?T — ein Platzhalter
der noch nicht in der Intern-Tabelle stehen kann weil sein finaler Typ
noch nicht bekannt ist.
Zwei-Phasen Ansatz¶
Phase 1 — Constraint-Generierung:
val x = [] → x: List[?T1]
x.push(42) → ?T1 = i32 (Constraint)
Phase 2 — Unifikation:
?T1 = i32 → löse auf
x: List[i32] → jetzt bekannt → intern → TypeId(X)
Erst nach der Unifikation werden finale TypeIds vergeben.
Warum das ein separater Mechanismus ist¶
Während der Inferenz-Phase braucht man temporäre, mutierbare Unifikationsvariablen die sich noch ändern können. Die Intern-Tabelle ist immutable — da passen Unifikationsvariablen nicht rein.
Nach der Inferenz: alle Variablen sind aufgelöst → alles wird intern'd → normale TypeIds.
Das ist keine Design-Schwäche — es ist inhärent in jedem System das Typinferenz implementiert.
Gesamtbild¶
Intern'd Strings → Namen als TypeIds
"User", "share", "Option" existieren je einmal
Intern'd Symbols → qualifizierte Namen als TypeIds
auth.User, Option.T — eindeutig im System
Intern'd TypeNodes → Typen als TypeIds
i32, User, Option[User] existieren je einmal
MetaRecord → System-Felder (direkt) + ext[] (user)
Teil der Typ-Identität
immutable nach Konstruktion
Alles in Arenen → @nogc throughout
kein GC, kein Heap-Alloc im Normalfall
Unifikationsvariablen → temporär, nur während Inferenz
werden danach zu echten TypeIds
Stand März 2026 — Work in progress.