Zum Inhalt

JDL Typsystem — Protocols, Konversionen und Typstaat

Fortsetzung von Teil 1: Grundlagen. Unser User-Management-System bekommt jetzt ein vollständiges Typsystem: Protocols definieren Verhalten, Operatoren werden überladbar, Konversionen typsicher, und Phantom Types erzwingen Geschäftsregeln zur Compile-Zeit.


1. Protocols und Implementierungen

Unser User braucht Gleichheit, Darstellung und Sortierung. In JDL definieren Protocols das Verhalten — unabhängig von den Daten:

protocol Equatable {
    def equals(self, other: Self) -> bool
}

protocol Comparable: Equatable {
    def compare(self, other: Self) -> Ordering
}

protocol Renderable {
    def render(self) -> str
}

protocol Inspectable {
    def inspect(self) -> str
}

Self — impliziter Typalias

In jedem Protocol existiert Self automatisch als Alias für den implementierenden Typ. provide Equatable for User bindet Self an User:

protocol Copyable {
    def copy(self) -> Self    // User.copy() -> User, nie etwas anderes
}

Implementierungen für User

provide Equatable for User {
    def equals(self, other: Self) -> bool =
        self.email == other.email    // E-Mail ist eindeutig
}

provide Renderable for User {
    def render(self) -> str = f"{self.name} <{self.email}>"
}

provide Inspectable for User {
    def inspect(self) -> str =
        f"User(name={self.name}, email={self.email}, age={self.age})"
}

Methoden direkt am Typ

provide User {
    def isAdult(self) -> bool = self.age >= 18

    def withEmail(self, email: str) -> User = User {
        name:  self.name
        email: email
        age:   self.age
    }
}

// Nutzung:
val updated = alice.withEmail("newalice@example.com")

Drei Formen von provide

provide Equatable for User { ... }        // Protocol für einen Typ
provide User { ... }                       // Methoden direkt am Typ
provide UserDb for InMemoryUserDb { ... } // Service-Handler-Bindung

AutoDerive

Statt manueller Implementierung kann derive Protocols aus den Feldern generieren:

type User: struct {
    name:  str
    email: str
    age:   i32
} :> Derive([Equatable, Hashable, Inspectable])

// Equatable: alle Felder vergleichen
// Hashable: alle Felder hashen
// Inspectable: Feldnamen + Werte ausgeben

1.5 Protocol-Typen in Signaturen

Protocols sind Verträge, keine instanziierbaren Typen. Trotzdem will man in APIs schreiben können, was man erwartet: „irgendein Generator“, „irgendein Iterator“, „irgendein Comparable“.

JDL behandelt Protocol-Typen in Signaturen daher als Typ-Sugar:

Parameterposition = implizites Generic

def size(g: Generator[T]) -> i64 = ...

desugart zu:

def size[T, G](g: G) -> i64
    where G: Generator[T] =
    ...

Rückgabeposition = opaquer Rückgabetyp

def take(g: Generator[T], n: i32) -> Generator[T] = ...

bedeutet: Die Funktion gibt einen konkreten, aber nach außen opaken Typ zurück, der Generator[T] implementiert. Das ist kein dynamisches Trait-Object, sondern compile-time monomorphisiert (ähnlich „impl Trait“ / „some Protocol“).

Normativ: Ein opaquer Protocol-Return darf nicht “von Laufzeitbedingungen” abhängen. Der konkrete Rückgabetyp muss pro Funktionsdefinition statisch bestimmbar sein (typischerweise ein Wrapper wie TakeGen[G, T]).

Coherence (Orphan-Regel)

Damit provide P for T nicht zur Import-Lotterie wird, gilt:

  • Eine Protocol-Implementierung ist nur erlaubt, wenn P oder T im selben Package/Modul definiert ist.
  • Für fremde Typen nutzt man Newtypes.

Das hält Dispatch eindeutig und verhindert, dass zwei Bibliotheken heimlich unterschiedliche Semantik für dieselbe Kombination (P, T) einschleusen.


2. Operator-Überladung

Unser System bekommt einen Statistik-Typ. Operatoren in JDL sind semantisch frei — + kann Addition, Concat oder Merge bedeuten. Was ein Operator tut, bestimmt der Typ über Protocols.

Ein Protocol pro Operator

protocol <{ operator: "+" }> Add {
    def add(self, other: Self) -> Self
}

protocol <{ operator: "-" }> Sub {
    def sub(self, other: Self) -> Self
}

protocol <{ operator: "*" }> Mul {
    def mul(self, other: Self) -> Self
}

protocol <{ operator: "/" }> Div {
    def div(self, other: Self) -> Self
}

protocol <{ operator: "%" }> Mod {
    def mod(self, other: Self) -> Self
}

protocol <{ operator: "-" }> Neg {
    def negate(self) -> Self
}

Das Meta-Record operator: verbindet Protocol und Symbol. Der Compiler liest es und weiß: wenn ein Typ Add implementiert, ist + verfügbar. Unäre und binäre Operatoren mit demselben Symbol (z.B. - für Sub und Neg) werden durch die Stelligkeit unterschieden — negate(self) vs. sub(self, other).

Statistik-Typ mit Operatoren

type <{ derive: [Add, Equatable], memory: Value }> UserStats: struct {
    totalUsers:  i64
    activeUsers: i64
    avgAge:      f64
}

// derive: [Add] generiert komponentenweise Addition aus den Feldern.
// Manuell wäre das:
provide Add for UserStats {
    def add(self, other: Self) -> Self = UserStats {
        totalUsers:  self.totalUsers + other.totalUsers
        activeUsers: self.activeUsers + other.activeUsers
        avgAge:      (self.avgAge + other.avgAge) / 2.0
    }
}

val today    = UserStats { totalUsers: 100, activeUsers: 80, avgAge: 32.5 }
val newBatch = UserStats { totalUsers: 20,  activeUsers: 18, avgAge: 25.0 }
val combined = today + newBatch

Semantische Freiheit

Nur weil + existiert, heißt das nicht dass - Sinn ergibt:

// Benutzerliste — nur Concat, keine Subtraktion
type UserList: struct { items: [User] }

provide Add for UserList {
    def add(self, other: Self) -> Self =
        UserList { items: self.items.concat(other.items) }
}

val all = admins + moderators    // UserList.add — Concat
// all - admins wäre ein Compile-Fehler: kein Sub für UserList

Asymmetrische Operationen — ScalableBy[S]

Wenn a * b verschiedene Typen hat:

protocol <{ operator: "*" }> ScalableBy[S] {
    def scale(self, factor: S) -> Self
}

type UserStats: struct { 
  totalUsers: i64 
  activeUsers: i64
  avgAge: f64 
} :> Derive([Add, ScalableBy[f64]])

val projected = today * 1.5    // alle Felder × 1.5

Vergleichsoperatoren

Vergleiche laufen über Equatable und Comparable mit demselben Meta-Record-Prinzip:

protocol <{ operator: "==" }> Equatable {
    def equals(self, other: Self) -> bool
}
// != wird automatisch als Negation von == abgeleitet

protocol <{ 
  operator: "<"
  derivedOps: ["<=", ">", ">="] 
}> Comparable: Equatable {
    def compare(self, other: Self) -> Ordering
} 

a == b desugared zu a.equals(b). a < b desugared zu a.compare(b) == Ordering.Less. Abgeleitete Operatoren entstehen automatisch aus compare.

// User hat Equatable (via derive), aber kein Comparable.
// alice == bob  → ok
// alice < bob   → COMPILERFEHLER: User hat kein Comparable

Convenience-Bündel

protocol Arithmetic: Add + Sub + Mul + Div + Mod + Neg {}

protocol Numeric: Arithmetic {
    def zero() -> Self
    def one()  -> Self
}

3. Typ-Konversion — CastTo[T]

Konversion ist ein gewöhnliches Protocol namens CastTo[T], gebunden an den Operator ->. Die provide ... from-Syntax existiert nicht mehr. Normativ spezifiziert in 07-cast-effekte-refinements.md § 1.

Unser System hat interne User und öffentliche UserResponse. Die Konversion zwischen beiden ist ein häufiges Pattern:

Verlustfreie Konversion — Lossless, darf implizit angewendet werden

type UserResponse: struct {
    name:  str
    email: str
    role:  str
}

provide CastTo[UserResponse] for User {
    def castTo(self) -> UserResponse = UserResponse {
        name:  self.name
        email: self.email
        role:  "User"
    }
}

Der Compiler wendet Lossless-Casts automatisch bei Zuweisungen an:

val user: User = alice
val response: UserResponse = user      // Lossless — implizit
val response  = user -> UserResponse   // Lossless — explizit

Fehlertyp-Konversion

Besonders nützlich mit =? — der Fehlertyp wird automatisch konvertiert:

type AppError: enum =
    | UserErr(UserError)
    | DbErr(DbError)

provide CastTo[AppError] for UserError {
    def castTo(self) -> AppError = AppError.UserErr(self)
}

// TODO: deps-Syntax veraltet — konsolidieren zu imperativem oder Effect-Stil
def<{ deps: [UserDb, LogService] }>
handleRegistration(name: str, email: str, age: i32) -> Result[UserResponse, AppError] {
    // registerUser gibt Result[User, UserError] zurück.
    // =? propagiert den Fehler — CastTo[AppError] for UserError greift automatisch.
    val user =? registerUser(name, email, age)
    LogService.info(f"Registriert: {user.name}")

    // Ok(user) — der Compiler wendet CastTo[UserResponse] for User an,
    // weil Ok[UserResponse] erwartet wird aber user: User vorliegt.
    Ok(user)
}

Verlustbehaftete Konversion — Narrow

Wenn Information verloren geht, markiert Narrow die Konversion als explizit:

type UserSummary: struct { name: str }

provide CastTo[UserSummary]: Narrow for User {
    def castTo(self) -> UserSummary = UserSummary { name: self.name }
}

val summary: UserSummary = alice            // COMPILERFEHLER — Narrow
val summary  = alice -> UserSummary         // Ok — explizit

Primitive Promotion

Für Primitive ist Widening eingebaut:

val a: i32 = 10
val b: i64 = a              // automatisch — i32 passt in i64
val c: i32 = b              // COMPILERFEHLER — Verlust
val c: i32 = b -> i32       // Ok — explizit

Regeln

Konversionen sind nie transitiv. A -> B und B -> C erzeugt kein automatisches A -> C. Konversion erzeugt immer einen neuen Wert — der Quellwert bleibt unberührt.


4. Newtypes, Refinements und Phantom Types

Newtypes — sichere IDs

Unser User braucht eine typsichere ID:

type UserId  = UserId(str)
type OrderId = OrderId(str)

def findUser(id: UserId) -> Result[User, UserError]

val userId  = UserId("u-123")
val orderId = OrderId("o-456")

findUser(userId)     // ok
findUser(orderId)    // COMPILERFEHLER: OrderId ist kein UserId

Refinement — der Wert IST der Basistyp

type Email = str :> ValidEmail

// Email IST ein str — kann überall verwendet werden wo str erwartet wird
def sendRawString(s: str) -> ()
sendRawString(email)    // kompiliert

Phantom Types — Typestate für unser User-System

Ein User durchläuft Phasen: Registriert → Verifiziert → Aktiv. Phantom Types erzwingen die Reihenfolge zur Compile-Zeit:

type UserPhase: enum =
    | Registered
    | Verified
    | Active

type ManagedUser[phantom P: UserPhase]: struct {
    id:    UserId
    name:  str
    email: str
    age:   i32
}

// Jede Phase hat exakt die Funktionen die ihr zustehen:
def register(name: str, email: str, age: i32)
    -> Result[ManagedUser[UserPhase.Registered], UserError]

def verify(user: ManagedUser[UserPhase.Registered], code: str)
    -> Result[ManagedUser[UserPhase.Verified], UserError]

def activate(user: ManagedUser[UserPhase.Verified])
    -> ManagedUser[UserPhase.Active]

// Compiler verhindert ungültige Übergänge:
val registered =? register("Alice", "alice@example.com", 30)
activate(registered)    // COMPILERFEHLER: Registered ≠ Verified

// Pflichtweg:
val registered =? register("Alice", "alice@example.com", 30)
val verified   =? verify(registered, "ABC123")
val active     = activate(verified)    // ok

Typ-Funktionen mit Phantom Types

typefn Capabilities[P: UserPhase] =
    match P {
        | UserPhase.Registered => CanView
        | UserPhase.Verified   => CanView | CanEdit
        | UserPhase.Active     => CanView | CanEdit | CanDelete
    }

Übersicht

Refinement Newtype Phantom Type + Kind
E-Mail-Validierung gut — bleibt str zu stark ideal
Typsichere IDs passt nicht gut auch möglich
Zustandsmaschinen nein nein perfekt

4a. Typparameter-Grammatik (normativ)

Grammatik

Jeder Typparameter folgt dieser festen Struktur:

TypeParam ::= Qualifier* Name (: KindConstraint)?

Qualifier      ::= "phantom" | "reified"
Name           ::= Identifier
KindConstraint ::= Protocol (+ Protocol)*

where-Klauseln folgen nach der vollständigen Parameterliste:

TypeParamList ::= "[" TypeParam (, TypeParam)* "]"
WhereClause   ::= "where" Relation (, Relation)*
Relation      ::= Name ":" KindConstraint

Die Reihenfolge innerhalb eines Parameters ist fix:

[ Qualifier*  Name  (:  KindConstraint)?  ]  (where  Relation (, Relation)*)?

Semantische Trennung

Inline-Kind-Schranke [T: X] — Constraint auf einen einzelnen Parameter, isoliert betrachtet. Der Compiler prüft bei jeder Instantiierung ob T den Constraint erfüllt. Mehrere Constraints am selben Parameter werden mit + kombiniert.

where-Klausel — Relation zwischen mehreren Parametern. Nötig sobald ein Constraint mehr als einen Parameter in Beziehung setzt. where ist nie nötig für Constraints die nur einen Parameter betreffen.

Prüffrage: Brauche ich Parameter U um den Constraint auf T auszudrücken? Wenn ja → where. Wenn nein → inline.

Anwendbar auf alle parametrischen Konstrukte

Die Grammatik gilt einheitlich für type, def, typefn und provide:

// type
type SortedList[T: Comparable]: struct { ... }

// def
def lookup[T: Comparable + Hashable](key: T, ...) -> Option[T] = ...

// typefn
typefn Wrap[T: Disposable] = Option[T]

// provide
provide Displayable for T where 
  T: Equatable + Printable { ... }

Beispiele — inline ausreichend

Ein einzelner Parameter, ein oder mehrere Constraints — alles inline:

// Einfacher Constraint
type PriorityQueue[T: Comparable]: struct { items: [T] }

// Mehrere Constraints am selben Parameter — inline mit +
type Registry[K: Hashable + Equatable, V]: struct { entries: Map[K, V] }

// Qualifier + Kind-Schranke — Qualifier vor Name, Schranke nach :
type StateMachine[phantom S: State, D: Disposable]: struct { data: D }

// Mehrere Qualifier sind möglich — feste Reihenfolge
type Buffer[reified phantom T: Measurable]: struct { ... }

Beispiele — where nötig

Sobald ein Constraint eine Relation zwischen Parametern ausdrückt:

// T und U in Relation — CastTo[U] braucht U
type TypedConverter[From, To]
    where From: CastTo[To]
    : struct { convert: From -> To }

// K und V unabhängig inline, aber K muss zu str konvertierbar sein (Relation zu str)
type SerializableMap[K: Comparable + Hashable, V]
    where K: CastTo[str]
    : struct { entries: Map[K, V] }

// Transition zwischen zwei Zustandsparametern
def transition[S: State, T: State, D: Disposable]
(machine: StateMachine[S, D], event: Event) -> Result[StateMachine[T, D], TransitionError]
    where S: TransitionTo[T]
= ...

Kombination — inline und where gleichzeitig

Inline für isolierte Constraints, where für Relationen. Beides ist in derselben Definition erlaubt:

type SortedMap[K: Comparable + Hashable, V, E]
    where K: CastTo[str]
        , E: From[V]
    : struct { entries: Map[K, V] }

Invarianten

  1. Qualifier kommen immer vor dem Parameternamen — nie danach.
  2. Kind-Schranke kommt immer nach : — nie vor dem Namen.
  3. where kommt immer nach der vollständigen [...]-Liste — nie darin.
  4. where ist ausschließlich für Relationen zwischen Parametern — nie für Constraints die nur einen Parameter betreffen.
  5. Mehrere where-Constraints werden mit , getrennt, nicht mit +.
  6. Die Qualifier-Menge ist geschlossen: phantom, reified. Erweiterungen erfordern Spracherweiterung.

Parser-Garantie

Die Grammatik ist kontextfrei und ohne Lookahead parsebar:

  • Qualifier-Tokens (phantom, reified) sind Schlüsselwörter — der Parser erkennt sie sofort.
  • : nach einem Identifier in einer Parameterliste leitet immer eine Kind-Schranke ein.
  • where nach ] leitet immer Relationen ein.
  • Kein Token ist in mehreren Positionen ambig.

5. Konstruktionslebenszyklus

Unser System hat Objekte die nicht frei erzeugt werden dürfen — z.B. Datenbankverbindungen. create-Policies kontrollieren das:

Freie Konstruktion — create: Trivial

type User: struct { name: str, email: str, age: i32 }
// create: Trivial (implizit) — Struct-Literal überall erlaubt

val alice = User { name: "Alice", email: "alice@example.com", age: 30 }

Kontrollierte Konstruktion — create: Factory[F]

type <{
    create: Factory[ConnectionPool]
    drop:   Custom
    share:  Local
    memory: Ref
}> DbConnection: struct { handle: HandleId }

provide ConnectionPool {
    def acquire(self) -> Result[DbConnection, PoolError] {
        val handle =? self.acquireRawHandle()
        Ok(DbConnection { handle })    // ok — factory-berechtigt
    }

    def release(self, conn: DbConnection) -> () {
        self.releaseRawHandle(conn.handle)
    }
}

// Außerhalb:
val conn = DbConnection { handle: h }    // COMPILERFEHLER
val conn =? pool.acquire()               // ok

Builder-Pattern

type ServerConfig: struct {
    host:    str
    port:    u16
    timeout: u32
} :> Create(Factory[ServerConfigBuilder])

type ServerConfigBuilder: struct {
    host:    Option[str]
    port:    Option[u16]
    timeout: u32
}

provide ServerConfig {
    def builder() -> ServerConfigBuilder = ServerConfigBuilder {
        host: None, port: None, timeout: 30_000
    }
}

provide ServerConfigBuilder {
    def withHost(self, host: str) -> Self {
        var b = self
        b.host = Some(host)
        b
    }

    def withPort(self, port: u16) -> Self {
        var b = self
        b.port = Some(port)
        b
    }

    def build(self) -> Result[ServerConfig, BuildError] {
        val host =? self.host.okOr(BuildError.MissingField { field: "host" })
        val port =? self.port.okOr(BuildError.MissingField { field: "port" })
        Ok(ServerConfig { host, port, timeout: self.timeout })
    }
}

// Verwendung:
val config =? ServerConfig.builder()
    .withHost("localhost")
    .withPort(8080)
    .build()

Phasenabhängige Konstruktion — Typestate + Factory

type ServerPhase: enum =
    | Unconfigured
    | Configured
    | Running

type <{ 
  create: Factory[HttpServerBuilder]
  drop: Custom
  share: Local 
}> HttpServer[phantom P: ServerPhase]: struct { 
  handle: HandleId 
}

provide HttpServerBuilder {
    def build(self) -> Result[HttpServer[ServerPhase.Unconfigured], BuildError] { ... }
}

def configure(s: HttpServer[ServerPhase.Unconfigured], cfg: ServerConfig)
    -> Result[HttpServer[ServerPhase.Configured], ConfigError]

def start(s: HttpServer[ServerPhase.Configured])
    -> Result[HttpServer[ServerPhase.Running], IoError]

def stop(s: HttpServer[ServerPhase.Running])
    -> HttpServer[ServerPhase.Configured]

start(unconfiguredServer)    // COMPILERFEHLER: Unconfigured ≠ Configured

Singleton — via CallGraph

Singletons sind kein Typ-Problem, sondern ein Instanziierungsproblem:

CallGraph app(req: HttpRequest) -> Result[HttpResponse, AppError] {
    requires: [UserDb, LogService]
    env: {
        UserDb:     PostgresUserDb { connectionString: config.dbUrl }
        LogService: ConsoleLogger {}
    }
    // genau eine Instanz pro Kontext — CallGraph garantiert Einmaligkeit
}

6. Type Utilities

Struct-Transformationen

type UserUpdate  = Partial[User]           // { name?: str, email?: str, age?: i32 }
type UserSummary = Pick[User, [.name, .email]]  // { name: str, email: str }
type CreateUser  = Omit[User, [.id]]       // alles außer id

Generische provide-Blöcke

provide[T] Retryable for T where T: Fallible + Concurrent {
    def retryPolicy(self) -> RetryPolicy = RetryPolicy {
        maxRetries: 3
        backoff:    Exponential
        baseDelay:  100ms
    }
}

Type Functions

typefn Partial[T] where T: Struct = struct {
    for field in T.fields {
        field.name: Option[field.type]
    }
}

typefn SafeReturn[T] =
    if T: Fallible then Result[T, JadeError]
    else T

7. Reifikation — Typen zur Laufzeit

JDL erhält Typinformation zur Laufzeit:

def describe(value: str | i32 | User) -> str =
    match value {
        | v: str  => f"String: {v}"
        | v: i32  => f"Zahl: {v}"
        | v: User => f"User: {v.name}"
    }

TypeInfo.of[T]() materialisiert Typ-Parameter als Laufzeitwert:

def describePhase[P: UserPhase](user: ManagedUser[P]) -> str =
    match TypeInfo.of[P]() {
        | TypeInfo.of[UserPhase.Registered]() => f"Registriert: {user.name}"
        | TypeInfo.of[UserPhase.Verified]()   => f"Verifiziert: {user.name}"
        | TypeInfo.of[UserPhase.Active]()     => f"Aktiv: {user.name}"
    }

Zusammenfassung Teil 2

Unser User-Management-System hat jetzt:

Protocols:       Equatable, Comparable, Renderable, Inspectable, Copyable
                 Self als impliziter Typalias, derive für Auto-Implementierung
Provide:         for (Protocol), direkt (Methoden), Service-Bindung
Operatoren:      Protocol pro Operator + operator:-Meta-Record (Präfix)
                 oder :> Operator("symbol") (Suffix)
                 Semantisch frei, ScalableBy[S] für asymmetrisch
                 Unär/binär durch Stelligkeit unterschieden
                 Vergleichsoperatoren über Equatable/Comparable
Konversion:      CastTo[T] — provide CastTo[Target] for Source (normativ in 07-cast)
                 Modi: Lossless (implizit), Narrow (explizit), Try (fallibel, =?)
                 Primitive Widening eingebaut, keine Ketten
Newtypes:        UserId, OrderId — nominale Unterscheidung
Phantoms:        ManagedUser[UserPhase.Registered] — Typestate
Konstruktion:    Trivial, Factory[F], Builder-Pattern
Type Utils:      Partial, Pick, Omit, typefn

Im nächsten Teil: Speicherverwaltung, Ressourcen-Management und Concurrency — unser System wird ein echter Server.