Zum Inhalt

JDL Grundlagen — Vom Script zur Anwendung

Fühlt sich an wie Python. Fängt Fehler wie Rust. Skaliert wie beides nicht.

JDL ist keine kompilierte Sprache im engeren Sinne — sie kann als Scripting-Sprache verwendet werden, Bytecode exportieren, embedden und deployen. Dieses Dokument zeigt den Weg von einem einfachen Script zu einer strukturierten Anwendung.

Durchgehendes Beispiel: Wir bauen Schritt für Schritt ein User-Management-System — von der ersten Zeile bis zur vollständigen Domänenlogik. Jedes Sprachfeature kommt dazu wenn es gebraucht wird.


Änderungstabelle

Alt (Spezifikation) Neu (dieses Dokument)
fn def
fn(T) -> U (Typ) (T) -> U / T -> U (Einzel-Arg)
&& \|\| ! and or not (Aliases — && \|\| ! bleiben gültig)
"a" ++ x.toString() f"a{x}" / f"""...""" (multiline)
let x = val x = (immutabel) / var x = (mutabel)
type X: enum { A, B } type X: enum = \| A \| B (neue Atome)
arm => expr, \| arm => expr
{ x => expr } (Closure) x => expr / { x => ... } (Block)
derive(Equatable, Hashable) <{ derive: [Equatable, Hashable] }> (Präfix) / :> Derive([Equatable, Hashable]) (Suffix)
f(name: "Alice") (Aufruf) f(name = "Alice")
(kein Inline-if) if cond then x else y

Unverändert: :> / <{ }> Meta-Records (siehe Dokument 0: Refinement-System), =?, where, typefn, service, CallGraph, Generics


1. Hello World — Werte und Ausgabe

val greeting = "Hello"
val name = "World"

Console.print(f"{greeting}, {name}!")

val bindet immutable. f"..." interpoliert Ausdrücke in {...}. Das ist ein vollständiges JDL-Script — direkt ausführbar, kein Boilerplate.

val vs. var

val name = "Alice"         // immutabel — kann nicht überschrieben werden
var counter = 0            // mutabel — kann überschrieben werden
counter = counter + 1      // ok
name = "Bob"               // COMPILERFEHLER: val ist immutabel

Kein const nötig

Ein val mit Literal-Zuweisung ist für den Compiler trivial als Compile-Zeit-Konstante erkennbar. Für explizite Garantien existiert das Typsystem:

type MaxRetries = 3                           // Literal-Typ — genau ein Inhabitant
type HttpSuccessCode = 200 | 201 | 204        // Union von Literalen

val maxRetries = 3                            // Compiler optimiert als Konstante
val defaultTimeout = 5000ms                   // Unit-Literal

2. Funktionen

// Einzeilig:
def double(x: i32) -> i32 = x * 2

// Block:
def greet(name: str) -> str {
    val msg = f"Hello, {name}!"
    msg
}

// Letzter Ausdruck ist Rückgabewert — kein `return` nötig

Generics

def identity[T](x: T) -> T = x

def mapList[T, U](xs: [T], f: T -> U) -> [U] {
    var out: [U] = []
    for item in xs {
        out.push(f(item))
    }
    out
}

4. Unser Domänen-Modell — Structs

Jetzt starten wir mit dem User-Management. Zuerst die Daten:

type User: struct {
    name:  str
    email: str
    age:   i32
}

val alice = User { name: "Alice", email: "alice@example.com", age: 30 }
Console.print(f"{alice.name} ist {alice.age} Jahre alt")

Keine Kommas zwischen Feldern — Zeilenende ist Trennzeichen.

Die :>-Annotation setzt Policies — dazu mehr in Teil 2:

type <{
    derive: [Equatable, Hashable]
    memory: Value
    share:  Sync
    drop:   Trivial
}> Point: struct {
    x: f64
    y: f64
}

5. Enums — Varianten und Unions

Einfache Varianten

type Role: enum =
    | User
    | Admin
    | Moderator

Varianten mit Payload

type Option[T]: enum =
    | Some(T)
    | None

type Result[T, E]: enum =
    | Ok(T)
    | Err(E)

Varianten mit benannten Feldern

Für unser System brauchen wir Fehlerwerte:

type UserError: enum =
    | NotFound      { id: str }
    | InvalidEmail  { email: str, reason: str }
    | AlreadyExists { email: str }

Union existierender Typen

type StringOrNumber: union = str | i64
type AnyError: union = UserError | IoError | DbError

Unions werden automatisch flach — Duplikate entfernt:

type A: union = X | Y
type B: union = Y | Z
type C: union = A | B    // äquivalent zu: X | Y | Z

6. Fehlerbehandlung — Result und =?

def validateEmail(email: str) -> Result[str, UserError] =
    if email.contains("@") then
        Ok(email)
    else
        Err(UserError.InvalidEmail { email, reason: "Kein @-Zeichen" })

def validateAge(age: i32) -> Result[i32, UserError] =
    if age >= 0 and age <= 150 then
        Ok(age)
    else
        Err(UserError.InvalidAge { age, reason: "Ausserhalb des gültigen Bereichs" })

=? propagiert Fehler sofort — ohne verschachtelte match:

def createUser(name: str, email: str, age: i32) -> Result[User, UserError] =
    val validEmail =? validateEmail(email)
    val validAge   =? validateAge(age)
    Ok(User { name, email: validEmail, age: validAge })

7. Pattern Matching

def describeUser(user: User) -> str =
    match user.role {
        | Role.Admin     => f"{user.name} ist Administrator"
        | Role.Moderator => f"{user.name} ist Moderator"
        | Role.User      => f"{user.name} ist normaler Nutzer"
    }

def handleResult(res: Result[User, UserError]) -> str =
    match res {
        | Ok(user)                          => f"OK: {user.name}"
        | Err(UserError.NotFound { id })    => f"Nicht gefunden: {id}"
        | Err(UserError.AlreadyExists { email }) => f"Existiert bereits: {email}"
        | Err(e)                            => f"Fehler: {e}"
    }

Match ist exhaustiv — der Compiler meldet fehlende Arme.


8. Option und Optional Chaining

def findUser(users: [User], email: str) -> Option[User] =
    users |> filter(u => u.email == email) |> first

val user: Option[User] = findUser(users, "alice@example.com")

// Optional Chaining:
val street = user?.address?.street    // -> Option[str]
val city   = user?.address?.city ?? "N/A"   // mit Fallback via ??

?? ist der Nil-Coalescing-Operator: opt ?? default entpackt oder nimmt Default.


9. Pipelines |>

val adminNames =
    users
    |> filter(u => u.active and u.role == Role.Admin)
    |> map(_.name)
    |> sort

val firstAdmin = adminNames |> first

10. Lambdas

// Concise:
users |> filter(u => u.active)
users |> map(u => u.name.trim())

// Placeholder:
users |> filter(_.active)
users |> map(_.name)

// Block:
users |> map { u =>
    val name = u.name.trim()
    f"{name} ({u.role})"
}

11. Strings

// Interpoliert:
val msg = f"Hallo, {alice.name}! Du bist {alice.age} Jahre alt."

// Raw — keine Escapes (Regex, Pfade):
val pattern = r"\d+\.\d+"
val path    = r"C:\Users\alice\Documents"

// Multi-line:
val sql = """
    SELECT name, email
    FROM users
    WHERE age >= 18
"""

// Multi-line interpoliert:
val query = f"""
    SELECT name, email
    FROM users
    WHERE age >= {minAge}
    LIMIT {limit}
"""

Einrückung wird normalisiert.


12. Collections

Arrays und Slices

val names: [str] = ["Alice", "Bob", "Charlie"]
val first = names[0]
val len   = names.len()

// Statisch großes Array:
val rgb: [u8; 3] = [255u8, 128u8, 0u8]

Tuples

val pos: (f64, f64) = (52.52, 13.405)
val (lat, lon) = pos

def minMax(xs: [i32]) -> (i32, i32) = (xs.min(), xs.max())
val lo, hi = minMax([3, 1, 4, 1, 5])

13. Services und Effekte

Unser User-System braucht persistente Speicherung. Statt einer konkreten Datenbank definieren wir ein abstraktes Interface:

service UserDb {
    def getUserById(id: str)        -> Result[User, UserError]
    def getUserByEmail(email: str)  -> Result[User, UserError]
    def insertUser(user: User)      -> Result[User, UserError]
    def listUsers()                 -> Result[[User], UserError]
}

service LogService {
    def debug(msg: str) -> ()
    def info(msg: str)  -> ()
    def warn(msg: str)  -> ()
    def error(msg: str) -> ()
}

Zwei Stile — eine Sprache

JDL kennt kein deps-Konstrukt auf Funktionsebene. Abhängigkeiten werden entweder als Parameter übergeben oder über Effect[R, E, D] im Rückgabetyp deklariert. Beide Stile sind vollwertig und gleichberechtigt.

Effect[R, E, D] ist ein normaler generischer Typ in der Stdlib — kein Compiler-Primitiv. Der Compiler hat kein Sonderwissen darüber. Die gesamte Semantik — Komposition, Retry, Remote-Ausführung, Concurrency — ist JDL-Code der auf diesem Typ aufbaut, genau wie Effect-TS TypeScript nur benutzt statt es zu erweitern.

Imperativer Stil — Services als Parameter, sofortige Ausführung:

def findUser(db: UserDb, log: LogService, email: str) -> Result[User, UserError] =
    log.debug(f"Suche User: {email}")
    db.getUserByEmail(email)

def registerUser(
    db:    UserDb
    log:   LogService
    name:  str
    email: str
    age:   i32
) -> Result[User, UserError] =
    val validEmail =? validateEmail(email)
    val validAge   =? validateAge(age)
    val user       = User { name, email: validEmail, age: validAge }
    val saved      =? db.insertUser(user)
    log.info(f"Registriert: {saved.name} <{saved.email}>")
    Ok(saved)

Effect-Stil — lazy Beschreibung, Dependencies im Typ, Runtime-Komposition:

def findUser(email: str) -> Effect[User, UserError, [UserDb, LogService]] =
    LogService.debug(f"Suche User: {email}")
    UserDb.getUserByEmail(email)

def registerUser(
    name:  str
    email: str
    age:   i32
) -> Effect[User, UserError, [UserDb, LogService]] =
    val validEmail =? validateEmail(email)
    val validAge   =? validateAge(age)
    val user       = User { name, email: validEmail, age: validAge }
    val saved      =? UserDb.insertUser(user)
    LogService.info(f"Registriert: {saved.name} <{saved.email}>")
    Effect.ok(saved)

Der Funktionskörper sieht in beiden Stilen nahezu identisch aus. Der Unterschied: der imperative Stil gibt einen Wert direkt zurück, der Effect-Stil gibt einen Effect-Wert zurück der erst durch explizites Bereitstellen der Dependencies ausgeführt wird. Das ist normale Typsemantik — kein Compiler-Sonderfall.

CallGraph — Wiring und Runtime-Verhalten

CallGraph verdrahtet Dependencies und ist eine echte serialisierbare Datenstruktur — kein Compiler-Mechanismus. Runtime-Verhalten wie Retry, Remote-Ausführung und Concurrency sind Refinements auf dem CallGraph:

CallGraph handleRequest(req: HttpRequest) -> Result[HttpResponse, AppError] {
    requires: [UserDb, LogService]
    env: {
        UserDb:     InMemoryUserDb { users: [] }
        LogService: ConsoleLogger {}
    }
    Node handle { call: routeRequest(req) }
    flow: handle
} :> Retry(max: 3, backoff: Exponential)
  :> Serializable

Weil der CallGraph eine Datenstruktur ist, kann er inspiziert, transformiert, geloggt, serialisiert und remote ausgeführt werden. Das ist kein Compiler-Feature — es ist die Stdlib die auf einem normalen Typ aufbaut.

Handler — konkrete Implementierung

type InMemoryUserDb: struct {
    users: [User]
}

provide UserDb for InMemoryUserDb {
    def getUserByEmail(self, email: str) -> Result[User, UserError] =
        match self.users |> filter(u => u.email == email) |> first {
            | Some(user) => Ok(user)
            | None       => Err(UserError.NotFound { id: email })
        }

    def insertUser(self, user: User) -> Result[User, UserError] =
        match self.getUserByEmail(user.email) {
            | Ok(_)  => Err(UserError.AlreadyExists { email: user.email })
            | Err(_) => {
                self.users.push(user)
                Ok(user)
            }
        }

    // ... weitere Methoden
}

CallGraph — Service-Wiring

CallGraph ist eine Datenstruktur die Dependencies verdrahtet und Runtime-Verhalten deklariert. Sie ist kein Compiler-Mechanismus — der Compiler prüft nur ob alle requires-Einträge in env vorhanden sind.

CallGraph handleRequest(req: HttpRequest) -> Result[HttpResponse, AppError] {
    requires: [UserDb, LogService]

    env: {
        UserDb:     InMemoryUserDb { users: [] }
        LogService: ConsoleLogger {}
    }

    Node handle { call: routeRequest(req) }
    flow: handle
}

Weil CallGraph eine echte Datenstruktur ist, können Runtime-Behaviors als Refinements hinzugefügt werden — ohne den Compiler zu berühren:

CallGraph handleRequest(...) { ... }
    :> Retry(max: 3, backoff: Exponential)
    :> Serializable
    :> Remote(endpoint: "worker-pool")

Das gesamte Verhalten — Retry, Remote-Ausführung, Concurrency — ist Stdlib-Code der auf CallGraph als normalem Typ aufbaut.


14. Function Unions

Unser Service-Interface lässt sich auch als Function Union ausdrücken — nützlich für Actor-Messages und RPC:

type UserCommand =
    | def register(name: str, email: str, age: i32) -> Result[User, UserError]
    | def find(email: str)                          -> Result[User, UserError]
    | def list()                                    -> Result[[User], UserError]
    | def delete(email: str)                        -> Result[(), UserError]

Ein Wert vom Typ UserCommand repräsentiert genau eine dieser Operationen inklusive ihrer Argumente.


15. Dokumentationskommentare

/// Registriert einen neuen Benutzer im System.
///
/// Validiert E-Mail und Alter bevor der User in die Datenbank
/// geschrieben wird. Schlägt fehl mit `AlreadyExists` wenn die
/// E-Mail bereits vergeben ist.
def registerUser(db: UserDb, log: LogService, name: str, email: str, age: i32) -> Result[User, UserError]

/// über einer Deklaration — von Tooling (LSP, Docs-Generator) auswertbar.


Zusammenfassung Teil 1

Wir haben ein User-Management-System mit folgenden Features:

Werte:           val (immutabel), var (mutabel)
Funktionen:      def f(x: T) -> U = expr  /  def f(x: T) -> U { ... }
Typen:           struct, enum (type X: enum = | A | B), union (type X: union = A | B)
Fehler:          Result[T, E], =? für Propagation
Optional:        Option[T], ?. für Chaining, ?? für Fallback
Pattern Match:   match expr { | Arm => ... }  — exhaustiv
Pipelines:       |> für Datentransformationen
Lambdas:         x => expr, _.field, { x => ... }
Strings:         f"...", r"...", """..."""
Services:        service, provide
                 Imperativer Stil:  Services als Parameter — sofortige Ausführung
                 Effect-Stil:       Effect[R, E, D] — normaler Stdlib-Typ, lazy
CallGraph:       Wiring-Datenstruktur — Refinements für Retry, Remote, Concurrency

Im nächsten Teil erweitern wir unser System mit dem Typsystem: Protocols, Operator-Überladung, Typ-Konversion und Phantom Types.