Zum Inhalt

Jade/JDL Typsystem — Persönliche Notizen

Zum Nachschlagen. Kein Teil der Specs. Stand: März 2026.


Inhaltsverzeichnis

  1. Das Effect System
  2. Row Polymorphismus
  3. Existential Types
  4. Die offenen Fragen im Typsystem
  5. TypeNode in D — die interne Repräsentation
  6. Interning
  7. MetaRecord
  8. reified — wann und warum
  9. Symboltabelle und Namespaces
  10. 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).

struct TypeId { uint id; }

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):

bool typesEqual(TypeId a, TypeId b) {
    return a.id == b.id;  // Fertig. Immer.
}

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:

DbConnection :> Share(Send)

...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:

DbConnection :> Share(Local)  →  TypeId(42)
DbConnection :> Share(Send)   →  TypeId(43)

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

module auth.User    // User aus auth
module billing.User // User aus billing — anderer Typ!

Beide heißen "User" — kurze Namen allein reichen nicht zur Identifikation.

Qualified Namespaces als Lösung

Der vollqualifizierte Name ist die Identität:

auth.User       →  TypeId(5)
billing.User    →  TypeId(6)

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

type Option[T] = | Some(T) | None
type Map[K, V] = ...

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.