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:
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¶
desugart zu:
Rückgabeposition = opaquer Rückgabetyp¶
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:
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¶
- Qualifier kommen immer vor dem Parameternamen — nie danach.
- Kind-Schranke kommt immer nach
:— nie vor dem Namen. wherekommt immer nach der vollständigen[...]-Liste — nie darin.whereist ausschließlich für Relationen zwischen Parametern — nie für Constraints die nur einen Parameter betreffen.- Mehrere
where-Constraints werden mit,getrennt, nicht mit+. - 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.wherenach]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.