Expressions Values Destack supports "expressions as values" where (almost) all statements are expressions that produce values, and the last expression (no trailing ;) becomes the value of the overall expression. const result = if (condition) { computeA() } else if (condition) { computeB() } else { computeB() }; function add(a: int, b: int): int { a + b // implicit return } If-let expressions enable nice sugar for matching a value with a refutable pattern in a conditional. Bindings from the pattern are available in the positive branch: const result = if (let value! = maybe) { value } else { 0 }; if (let (x, y) = point) { print(x + y); } do { ... } turns a block into an expression when braces would otherwise be ambiguous with an object expression or statement block. The final expression without a trailing semicolon becomes the block value. const user = do { const record = loadUser(id)?; User.fromRecord(record) }; Closures Closures in Destack work essentially like TypeScript's closures, capturing the surrounding lexical environment and preserving lexical this around a generic Function. "Arrow function types" are syntax sugar for that form, so (message: string) => Result is the same type as Function<(string,), Result>. let count = 0; const next = () => { count += 1; return count; }; next satisfies () => number; next satisfies Function<(), number>; const read = () => count; All Functions are fat pointers capable of capturing an environment by default, and when a true thin pointer is required, we can just use FunctionPointer. Thus, Functions also behave more like Dynamic by default, and explicit generics are required to force monomorphisation: function apply int32>(callback: F, value: int32): int32 { return callback(value); } As with all of Destack, the Functions behind closures behave like one would expect in TypeScript by default, with additional control available on demand via the regular memory modifiers like &Function<(string,), void>. By default, captures preserve variable identity: the captured variable's storage is automatically managed by the compiler, and every closure that captures that variable observes the same storage. The capture policy can be configured via the @capture decorator: | Policy | Meaning | | --- | --- | | "manage" | preserve variable identity through compiler-managed storage | | "borrow" | capture borrowed access to the original binding | | "copy" | snapshot the current value | | "move" | move the binding into the closure | Explicit capture forms choose how the callable value itself will be stored in the closure environment: let count = 0; @capture("borrow") let borrowed: &Function<(), int32> = () => count; let value = 0; @capture("move") let owned: ^Function<(), int32> = () => value; let state = 0; let managed: Function<(), int32> = () => state; The short form for @capture sets the default for every captured binding, and the object form overrides selected bindings, including this: class Client { prefix: string; make(socket: Socket, logger: Logger): (message: string) => Result { @capture({ default: "copy", socket: "move", logger: "borrow", this: "borrow", }) return (message) => { logger.info("sending"); return socket.write(`${this.prefix}: ${message}`); }; } } Of course, closures with custom capture behavior must still follow general ownership rules - for example, if one closure moves a binding, later uses or captures of that binding are rejected. Continuations Async functions and generators are closures that can pause and be resumed later via stackful Continuations: the runtime parks the live frame and hands back an ordinary owned value whose one-shot resume(value) continues the frame and whose Drop cancels it, under the usual ownership rules. Promise, Generator, and AsyncGenerator are standard library types that store such continuations in ordinary fields: | Form | Meaning | |------|---------| | Continuation | one-shot owned continuation, Drop cancels it | | Promise | Worker-local async result object | | Generator | Worker-local suspended generator | | AsyncGenerator | Worker-local suspended async generator | | produced T | value eventually produced by async code | As in TypeScript, await and yield are (stackful) suspension points: the entire stack up to that point is parked, and some other task gets to run. In pure managed land, suspension works as before, and managed values can be stored in parked frames because it's all - well - managed. type User = { name: string; }; async function read(user: User): Promise { const name = user.name; await tick(); return name; // valid as before } Tasks Stackful continuations and (relatively) cheap Workers make Destack's concurrency ergonomic and structured by default, while also keeping TypeScript's familiar Promise behavior: calling an async function starts it eagerly and returns a worker-local Promise that can be stored, combined, and awaited as usual. There is no special machinery required for this - pending handles live in ordinary places (like a Promise's reactions, the scheduler's queue, a Task), so structure is just storage and cancellation is just Drop. Nice. Work that outlives its frame can be spawned into an explicit TaskScope, which cannot exit until its children complete or are cancelled (the structured concurrency model of Trio and Kotlin): async function crawl(seeds: [Url]): Promise { await using scope = TaskScope.open(); const pages = seeds.map((seed) => scope.spawn(() => fetch(seed))); const results = await Promise.all(pages); return Report.from(results); } // the scope cannot exit while children are pending; pending children are cancelled on unwind Cancelling a task resumes its parked continuation into the unwind path at its suspension point, cleanup (using / finally / Drop) runs deterministically, and the unwind stops at the task boundary - the Worker carries on, and Cancelled surfaces only at join(). By the time a frame exits normally, everything it started must be awaited, returned, or handed to a scope - the no-floating-promises rule, deny by default in .ds (strict TypeScript codebases already lint this, Destack just means it) - and when a frame unwinds instead, its still-pending children are cancelled: async function refresh(cache: Cache): Promise { fetchAndStore(cache); // ERROR: floating promise, await it or hand it to a scope } Because a scope guarantees a join before the parent frame dies, borrows of owned or static data may also cross into scoped child tasks (subject to Send) - fork-join parallelism over borrowed data without ceremony (!). And because the runtime owns continuation scheduling, our DST mode can intercept every suspension point and replay or explore schedules deterministically. Patterns TypeScript has pattern based destructuring for arguments and assignment-like expressions, and Destack extends that idea into match, if (let ...), let ... else, and catch match with a full suite of patterns for every type family: | Family | Example | Meaning | |--------|---------|---------| | Wildcard | _ | match and ignore the value | | Binding | value | bind the matched value | | Literal | "ok", 0, true | match one literal value | | Range | 0..10, ..=255 | match an integer, bigint, or char interval | | Tuple | (x, y) | destructure a tuple value | | Sequence | [head, ...tail] | destructure finite ordered elements | | Object | { kind: "ok", value } | destructure a structural object | | Nominal object | Point { x, y }, User { name } | match a nominal object-shaped value and destructure stored fields | | Nominal tuple | UserId(value), Config({ debug }), Shape.Circle({ radius }) | match a nominal tuple-shaped head, then resolve it as a newtype or tagged variant | | Enum | State.Ready | match a nominal enum variant without payload | | Union | 0 | 1 | 2 | accept any listed pattern | | Rest | ...tail | bind the remaining sequence view or object fields | | Default | name = "guest" | bind a fallback when the selected value is undefined | | Must | value! | bind the non-nullish value | | Borrow binding | &readonly value, &value, &exclusive value | bind the selected place through a borrow | | Move binding | ^value | bind the selected place by ownership | | Dereference | *Point { x, y } | dereference the selected place before matching | | Guard | pattern if (condition) | require an extra boolean condition | For exhaustive matching with those patterns, Destack supports the match expression: match (result /* Result */) { Ok { value } => process(value) Err { error } if (isRetryable(error)) => retry() Err { error } => fail(error) } Guarded arms narrow their own bodies, but they do not contribute to exhaustiveness by themselves (because the guard can reject a value that the pattern matched). Like with other conditional expressions, the resulting type of a match expression is the union of its arms' types. declare const point: Point; match (point) { Point { x: 0, y: 0 } => "origin" Point { x, y } => `at ${x}, ${y}` // irrefutable if point: Point } Some patterns are irrefutable, meaning they always match, and then no fallback branches are needed at all. Refutable patterns require some fallback such that all branches are always covered: a match fallback arm, an else branch for if (let ...), or an else continuation for let ... else. declare const point: Point; match (point) { Point { x: 0, y } => "vertical" Point { x, y: 0 } => "horizontal" _ => "neither" // required fallback } declare const result: Result; let Result.Ok(value)! = result else { return Result.err("missing value"); }; Unlike with construction ({ ... } for objects, T { ... } for structs, new T(...) for classes), the pattern destructuring unifies structs and classes into a single nominal object pattern (T { ... }). Admittedly, this is a little suboptimal since it's not perfectly symmetrical, but we couldn't think of a more reasonable syntax that's not ambiguous or "magic" in some worse way. class User { name: string = ""; get displayName(): string { return this.name; } } declare const user: User; match (user) { User { name } => name } The patterns match only real fields, not getters or setters: match (user) { User { displayName } => displayName // ERROR: getter } Computed object pattern keys must close to static terms when matching finite object-shaped values. Dynamic computed keys are still valid against indexed sources, because those are resolved through the ordinary Index / IndexSet surface rather than through declared fields. Guards Guards are boolean expressions that can refine types, like "name" in value, instanceof, and value is T checks: - "name" in value for object-shaped values. - key in value through Has for custom containers. - instanceof for classes. - value is T for primitive tags, union cases, exact runtime types. Destack does not support the vague typeof check, and instead supports an additional precise value is T to check whether the current runtime representation of value carries the case, type identity, or registered relation for T: struct User { name: string; } function label(value: User | string): string { if (value is User) { return value.name; } else { return value; } } Like other guards, value is T returns boolean and narrows the branch: - When true: narrows to the part of its current type that can be T. - When false: narrows away the covered part (when that can be represented). For union values, the guard test sees "through" the union payload: const value: string | int32 = 1; if (value is string) { value satisfies string; } else { value satisfies int32; } Loops For convenience and clarity, Destack supports loop as the explicit infinite loop form, and like other expressions, loops can produce a value through break. const line = loop { const input = readInput(); if (input == "quit") { break "done"; } process(input); }; let status = outer: loop { break outer: "done"; }; status satisfies "done"; The break operand works like TypeScript labels by default, the break only get s a value when it is unambiguous via either label: or just break (where the cannot be identifier shaped). Using Resource management with using and await using follows the TC39 explicit resource management proposal, but of course with nominal interfaces instead of magic Symbol keys: - using accepts Dispose | null | undefined. - await using accepts AsyncDispose | Dispose | null | undefined, and falls back to synchronous disposal when the resource only implements Dispose. - null and undefined are ignored, following the spec. Resources are cleaned up at lexical scope exit in LIFO order, and await using runs async cleanup when required. Cleanup - that is, the dispose function - runs when the scope exits for any reason: fallthrough, return, break, continue, or ?. The same using form also works in loop form, where it applies for every iteration. { using input = openFile(inputPath), output = openFile(outputPath); copy(input, output); } // output is disposed, then input is disposed async function runQuery(sql: string): Result { await using connection = await pool.connect(); return await connection.query(sql); } // connection is disposed and awaited for (using file of files) { process(file); } // file is disposed after each iteration Operators Destack extends TypeScript operators with typed overloads and some additional precision. Logical operators (&&, ||, ??), optional chaining, assignment, and strict identity (===, !==) are not (directly) overloadable, as usual, and same for increment (++) and decrement (--). Compound assignment operators like += are desugared into their component operations (+ and =), and are thus indirectly overloadable. | Operator | Example | Interface | |----------|---------|----------| | + | a + b | Add | | - | a - b | Subtract | | * | a * b | Multiply | | / | a / b | Divide | | % | a % b | Remainder | | ** | a ** b | Power | | + | +a | Plus | | - | -a | Negate | | & | a & b | And | | \| | a \| b | Or | | ^ | a ^ b | Xor | | ~ | ~a | Not | | << | a << b | ShiftLeft | | >> | a >> b | ShiftRight | | >>> | a >>> b | ShiftRightUnsigned | | ==, != | a == b | PartialEqual | | <, <=, >, >= | a < b | Compare or PartialCompare | | in | key in value | Has for custom containers | | [] | a[i] | Index | | [] = | a[i] = v | IndexSet | | * | *a | Dereference<"readonly"> | | * = | *a = v | Dereference<"mutable"> & T extends OverwriteStable or Dereference<"exclusive"> | Equality == / != follows Rust's split between "partial" and "total" equality: - Equality operators == and != dispatch through PartialEqual.equal - Equal is a stronger marker interface based on PartialEqual The distinction between Equal and PartialEqual exists mainly to deal with the oddities of floating point numbers (since e.g. NaN does not equal itself, definitionally). Comparison operators < / > follow the same pattern and support PartialCompare where ordering may be undefined (e.g., floats), and Compare when ordering is total (e.g., integers). Strict identity === still keeps its TypeScript meaning: by value for primitives (including string and char contents, and NaN === NaN is still false), and by reference identity for managed objects. Dereference operators are a little different from the main "value-shaped" operators, because Dereference transparently dereferences (projects) access forms on use. The * operator projects the place behind the borrow returned from Dereference.dereference(), so the dereference expression has the pointee type while writes back via the returned indirection. struct Box { ptr: ^T; } extension of Box implements Dereference { type Output = T; dereference(): WithAccess<&T, A> { &this.ptr } } For example, *box flows via Dereference<"readonly">, while assignment through *box requires Dereference<"mutable"> when the value is OverwriteStable and Dereference<"exclusive"> otherwise. Importantly, member lookup and method calls may auto-dereference transparently through Dereference without any special syntax - this is what enables ergonomic access to smart pointer like wrappers and guards. Arithmetic Destack's sized numeric types are checked for correctness in all modes, no ifs or buts, no debug-only checking and no silent release-mode wraparound: - Overflow traps. Arithmetic on sized integer types (+, -, *, **, <<, negation) traps when the result does not fit the type. - Division traps. Integer division and remainder by zero trap (/, %), as does overflowing division (int32.MIN / -1). - Floats follow IEEE-754. Float arithmetic never traps; division by zero, NaN, and infinities behave exactly as in TypeScript. When modular or clamping semantics are desired, we can say so explicitly with the standard library helpers: declare const a: uint8; declare const b: uint8; a.wrappingAdd(b) satisfies uint8; // modular arithmetic a.saturatingAdd(b) satisfies uint8; // clamps at the bounds a.checkedAdd(b) satisfies uint8 | undefined; // detects overflow as a value Wrapping(a) + Wrapping(b); // wrapping by type Conversions follow the same explicitness rule as the rest of Destack: as between numeric types is allowed only when every value of the source type is representable in the destination (lossless), and lossy conversions must pick their behavior explicitly: declare const wide: int32; const a: int64 = wide as int64; // OK: lossless widening const b: uint8 = wide as uint8; // ERROR: lossy conversion const c = wide.truncate(); // explicit: keep the low bits const d = wide.saturate(); // explicit: clamp into range const e = wide.tryInto(); // explicit: uint8 | undefined Similarly, mixed-type arithmetic (different widths or int/float mixes) requires explicit conversion to a common type first; the only implicit numeric flow is literal typing as described in Primitives. Ranges Range expressions like a..b produce range values for slicing, indexing, iteration, and any APIs that want to think in terms of bounds. Like Rust and Python, the default range is half-open, and all range forms implement RangeBounds, whose startBound() and endBound() methods return Bound: | Expression | Type | Meaning | |------------|------|---------| | start..end | Range | Include start, exclude end | | start..=end | RangeInclusive | Include both bounds | | start.. | RangeFrom | Include start, no end bound | | ..end | RangeTo | No start bound, exclude end | | ..=end | RangeToInclusive | No start bound, include end | | .. | RangeFull | No start or end bound | Ranges work in patterns and subscripts exactly like one would expect from other languages: let values: Slice = [1, 2, 3, 4, 5]; (&values)[1..4] satisfies &Slice; (&readonly values)[1..4] satisfies &readonly Slice; (&exclusive values)[1..4] satisfies &exclusive Slice; Dispatch "Dispatch" is how calls, member accesses, and overloadable operators select the specific field or method to use. Destack's overload resolution rule is based on TypeScript: build the candidate set, keep candidates compatible with the arguments as written, then pick the first match in declaration order: Overloads Unlike in TypeScript, there may be multiple overloaded implementations for the same name and scope: function parse(input: string): int32 { return parseInt(input); } function parse(input: int32): int32 { // legal, actually different implementation! return input; } Members work in the same way (after receiver lookup): inherent members first, then visible extension members in declaration order. Because Destack has strict types, property access and method calls may use different access paths; that is, a field and method can share a source name: value.name resolves the field/accessor projection, while value.name() resolves the method-call projection. (Fields and accessors share the property projection and therefore cannot share a name.) Operators For overloadable operators, the operator decides the interface to check, and the left operand is the receiver (matching how it is written in the interface implementation). Binary operators do not fall back to the right operand, so if both operand orders are desired - a + b and b + a, both receiver implementations must exist. newtype interface Add { type Output; add(other: T): this.Output; } extension of Vector2 implements Add { type Output = Vector2; add(other: Vector2): this.Output { Vector2({ x: this.x + other.x, y: this.y + other.y }) } } extension of Vector2 implements Add { type Output = Vector2; add(other: float32): this.Output { Vector2({ x: this.x + other, y: this.y + other }) } } const moved = position + offset; // Add const padded = position + 1.0; // Add moved satisfies Vector2; padded satisfies Vector2; 1.0 + position; // requires Add on float32 Interfaces As discussed in Types, structural interfaces keep normal TypeScript shape checking and mostly work exactly as expected: bare structural interface annotations are constraints, so point: PointLike behaves like an implicit T: PointLike parameter and is specialized for the concrete argument type. Because structural interfaces are satisfied by shape, writing implements on one is only an explicit declaration-site check. Erased interface values are spelled explicitly with Dynamic. interface PointLike { x: int32; y: int32; } struct Point implements PointLike { y: int32; x: int32; } function lengthSquared(point: PointLike): int32 { point.x * point.x + point.y * point.y } One point of difference to TypeScript is mutability through interfaces, because we need to actually compile the code into something with a fixed shape. Interface fields are still read-write by default, but concrete fields satisfy mutable structural fields only when their types match exactly (after normal alias and newtype normalization). interface PointLike { readonly x: int32 | float32; } struct Point { x: int32; } const point: PointLike = Point { x: 1 }; point.x satisfies int32 | float32; Readonly fields only need, well, reads, so they can widen through ordinary implicit casts and nested structural views. However, if PointLike.x were mutable, this conversion of Point to PointLike would be rejected because writing through PointLike would no longer be correct (it would have to implicitly widen, but it doesn't and cannot know that!). Index Signatures Index signatures like { [index: string]: string } (as in Record) still work like structural constraints for object-shaped values, and can be satisfied with both fixed object shapes and types implementing Index for readonly / IndexSet for writable shapes. While Destack supports structural index signatures, the actual compiled shape must still be known, and so Record-like types by themselves are not concrete (they're just constraints). interface Bag { readonly [key: string]: T; } // finite object view const counts: Bag = { apples: 3, oranges: 2 }; function read(bag: Bag, key: string): T | undefined { bag[key] } read(counts, "apples") satisfies int32 | undefined; // custom indexed containers use operator interfaces instead const dynamicCounts = new Map(); dynamicCounts.set("apples", 3); dynamicCounts.has("apples") satisfies boolean; dynamicCounts satisfies Index; dynamicCounts satisfies IndexSet; dynamicCounts satisfies Has; Unions Member dispatch on union receivers is resolved per variant, and every variant must expose that member. If all variants resolve to the same implementation, the call is static; otherwise the result type is the union of the selected return types and the compiler emits dynamic checks to dispatch on the right member at runtime. struct TcpStream { write(chunk: [uint8]): Result {} } struct MemoryBuffer { write(chunk: [uint8]): Result {} } function writeAll(sink: TcpStream | MemoryBuffer, chunk: [uint8]) { const written = sink.write(chunk); written satisfies Result | Result; } Coherence Unlike Rust, Destack has no orphan rule: any module may extend any nominal type and implement any nominal interface for it. Because Destack always compiles whole programs from source, coherence can be enforced where it is actually needed instead of at every possible declaration site, which is quite nice: | Claim | Scope | Conflict | | --- | --- | --- | | Extension members | lexical: same file, or explicitly imported | ambiguity error at the use site | | implements declarations | global: unique per (type, interface instantiation) pair | compile error pointing at both declarations | | Operator and capability dispatch | global: works wherever the interface is nameable | none possible, implementations are unique | For extension members, candidates resolve in declaration order within one scope, import order is never considered, and an overload that can never win is flagged as unreachable. For implements, uniqueness covers blankets too (no specialization, and bare-bounded blankets only from the interface's package, see Blankets) - and it is what keeps generic instantiation coherent: a Set is always built and queried under the same Hash implementation, no matter which modules the value flows through. When two packages do collide, the build fails and the fix is source-level (drop a dependency, or vendor and patch) - whichever side "won" would silently change the other's behavior, so there is deliberately no switch to pick one. Libraries should therefore only implement pairs they own one side of (a default-warn diagnostic nudges accordingly); foreign-on-foreign implementations belong in applications, where nothing downstream can collide. Errors The banishing of exceptions is Destack's most immediately noticeable divergence from TypeScript: Destack uses Result-first error handling exclusively, and throwing exceptions is not allowed in any native Destack code. Recoverable errors use Result, integrate with try / catch, and can be propagated with ?, ??, and force-unwrapped postfix !. (JavaScript exceptions remain valid syntax because we need to integrate with JS targets directly, but in regular userland, exceptions are forbidden.) The same rule also extends to async code: a Destack Promise never rejects, because rejection is just an asynchronous exception. Async failure travels as AsyncResult, panics unwind the Worker, and rejection-capable host promises are adopted into AsyncResult (or panic) at the host binding boundary. Error Like in Rust, types that want to be handled as general errors explicitly implement the nominal Error interface: newtype interface Error { display(): string; source(): Dynamic | undefined { undefined } } Result Destack provides Result as the primary error handling mechanism: export struct Ok { kind: "Ok" = "Ok"; value: T; } export struct Err { kind: "Err" = "Err"; error: E; } export newtype Result = Ok | Err; We typically construct results through Result.ok(value) and Result.err(error). The variants are ordinary nominal data, so pattern matching works directly: declare function parseInteger(raw: string): Result; function parsePort(raw: string): Result { const value = parseInteger(raw); if (value < 0 || value > 65535) { return Result.err(ParseError(`port out of range: ${raw}`)); } else { return Result.ok(value as uint16); } } match (parsePort(input)) { Ok { value } => connect(value) Err { error } => report(error) } Result is great for synchronous error handling, and AsyncResult extends the exact same idea to Promise-based asynchronous errors with a convenient wrapper around Promise>. export newtype AsyncResult = Promise>; declare function fetchUser(id: UserId): AsyncResult; async function loadProfile(id: UserId): AsyncResult { const user = await? fetchUser(id); // `await? expr` is sugar for `(await expr)?` const profile = decodeProfile(user)?; return Result.ok(profile); } For await, we also have some await? expr sugar for (await expr)?, and await! expr is sugar for (await expr)!. Maybe, Must and Coalesce ?, postfix !, and ?? all unwrap the same Try-based absence-or-failure shape. Opening removes outer null / undefined, opens one Try carrier, and removes null / undefined from the carrier's success value. It's much simpler than it sounds: declare const x: Result | null | undefined; // x? // -> success: T // -> failure (propagated): E | null | undefined Nullish values on the failure side remain in the failure side. The three operators differ on the failure case: x? // success T, failure leaves the expression x! // success T, failure traps x ?? y // success T, failure evaluates y The try operator ? keeps the success value and lets absence or failure leave the current expression in whichever way the container requires. Inside a try block with catch, propagation transfers the failure value to the catch instead. function readConfig(path: string): Result { const text = readFile(path)?; const json = parseJson(text)?; return Result.ok(Config.from(json)); } The try-coalesce operator ?? accepts the same shape locally "within" the expression with a direct fallback instead of letting it bubble up to the container as with ?. The result of Result ?? F is the non-nullish opened success type joined with the fallback type T | F: declare const defaultConfig: Config; declare function loadConfig(): Result | null; const a = loadConfig() ?? defaultConfig; a satisfies Config; declare function loadMaybeConfig(): Result | undefined; const b = loadMaybeConfig() ?? defaultConfig; b satisfies Config; All unwrap operators unwrap exactly one layer of Try, so nested Try values inside the success type also stay wrapped at the inner layer: declare function loadNested(): Result, IOError>; const c = loadNested() ?? defaultConfig; c satisfies Result | Config; Postfix ! is the "must" forced unwrap form: it opens the same outer nullish and single Try layer, but traps instead of propagating or falling back when the value is absent or failed: const config = loadConfig()!; config satisfies Config; Regarding precedence, whitespace decides between the try operator and a ternary, which should mostly follow how we would naturally type (and format) these expressions anyway: an attached question mark is try-propagation, a detached one is a ternary condition. This keeps the branches unambiguous in both directions: flag ? -x : x is a conditional, and x? - 1 subtracts from the opened success value (so a compact ternary requires its spaces in .ds). Try The "try" operators ?, ?? and ! are all based on the builtin Try operator interface, much like in Rust, A carrier has success and failure types, can branch into either case, and can then rebuild itself from a success value: struct TryContinue { kind: "continue" = "continue"; value: T; } struct TryFailure { kind: "failure" = "failure"; failure: F; } type TryBranch = TryContinue | TryFailure; newtype interface Try { type Value; type Failure; static fromValue(value: this.Value): this; branch(): TryBranch; } For Result, Ok { value } branches to TryContinue and Err { error } branches to TryFailure. Try-Catch-Finally The well known try/catch forms still work with explicit Try propagation, which is quite cool: declare function readConfig(path: string): Result; try { const config = readConfig("config.json")?; process(config); } catch (e) { // e: IOError log("failed to read config:", e) } The example above uses Result, but any type implementing Try can be used: - try provides an error propagation context (but does not unwrap Result values by itself) - Use ? inside the block to propagate Try failures into the catch - Use ?? inside the block when the failure should be handled locally with a fallback For convenience, Destack also supports a nicer catch match form that can branch on Try failures directly for some pretty pleasant syntactic sugar: try { let config = readConfig()?; // -> Result config satisfies RawConfig; let config = parseConfig(config)?; // -> Result config satisfies Config; // ... do stuff with config ... } catch match (failure) { // failure: MissingError | FormatError MissingError { path } => Report.wrap(failure, `missing config: ${path}`) FormatError { line } => Report.wrap(failure, `bad format on line ${line}`) } Finally arms run as usual after the try / catch body, including when ? leaves the block early. Oh, and the whole try-catch-finally form is an expression like any other, so we can still do let result = try { ... } and scope the operation and result directly that way as well. Panics Unrecoverable failures are hard panics: panics occur when a must unwrap (!) fails, when an explicit panic("...") runs, when a runtime check traps (arithmetic overflow, out-of-bounds indexing, lone-surrogate indexing), or when unreachable code is reached. There is no userland catch for panics, that's what makes them panics: a panic means the program is outside its specified envelope. Mechanically, a panic unwinds the current Worker: 1. The panic starts unwinding from the trapping point with a message payload. 2. Cleanup runs on the way out - using / await using disposal, finally arms, and Drop glue for owned values - in the usual LIFO order. 3. The Worker terminates; a parent or supervisor observes the termination and gets the Panic struct - message, source location, and stack trace when available - through the regular Worker API and decides what to do (restart, propagate, report). 4. A panic during that cleanup aborts: there is no unwinding the unwinding. Conveniently, the Worker thus also becomes the fault boundary, mirroring both the web's worker model and (roughly) Erlang-style supervision: a panic never silently corrupts sibling Workers, and the test harness and the simulator can observe panics as ordinary (deterministic) Worker terminations without any language-level catch. Nice. For cases where we do want to unambiguously kill the whole program, Destack supports a stronger abort for genuinely unrecoverable states like detected memory corruption: | Form | Cleanup | Boundary | | --- | --- | --- | | panic | unwinds with using / finally / Drop cleanup | terminates the Worker | | abort | none, stops immediately | terminates the whole process | Trees (TSX) TypeScript XML (.tsx) is a great way of writing UI-shaped code and has even seen some successful adoption for other tree-shaped data structures as well. It's not perfect, but it is very useful in many situations, and Destack (.ds) natively supports .tsx-like constructs with the same rules: // Wall.ds ; // Prompt.ds You are a helpful assistant. {userMessage} ; // Level.ds {enemies.map(e => )} ; Unlike in TypeScript, Destack types can participate in custom tree tag behavior by implementing the TreeTag interface, and custom intrinsic tags (lowercase tags like
) are created via TreeTagBuilder. Essentially, TreeTag generalizes jsxFactory and TreeTagBuilder generalizes jsxFragmentFactory: - Uppercase or qualified tags resolve as value tags through normal value lookup and the TreeTag interface. - Lowercase unqualified tags resolve as intrinsic tags through the active TreeTagBuilder. The active TreeTagBuilder comes from the compiler / target / profile options. Decorators Like TypeScript, Destack uses @ for decorator-like constructs, but Destack supports both "annotations" and "decorators", and also many more constructs can be annotated / decorated. The syntax for both data annotations and behavior decorators is unified, the target - the thing pointed to in @ - decides: - Annotations are values like newtypes. They add typed metadata to the target, but don't directly change the target's behavior. - Decorators are logic following some protocol that contribute code or change the analyzed shape in some bounded way. Annotations are "inert" by default, that is, they don't do anything until either some userland construct or the toolchain give them special meaning or implement the Macro protocol. newtype deprecated = () | (string,); @deprecated("use newAPI instead") // metadata annotation, doesn't do anything function oldAPI() { // ... } @tracked @derive(Clone, Debug) struct User { id: UserId; name: string; } Diagnostics Like in other languages, (some of) Destack's diagnostics can be tuned with scoped decorators: - @allow: explicitly allow a specific diagnostic - @warn: warn about a specific diagnostic - @deny: error about a specific diagnostic - @forbid: forbid a specific diagnostic (cannot be overridden by @allow) - @expect: expect a specific diagnostic (suppress, error if not produced) @allow("no-floating-promises", { if: import.meta.dev, otherwise: "deny", reason: "debug telemetry", }) module {} Restrictions Relatedly, restrictions may be used to allow or disallow more fundamental reaching language behavior in certain scopes: @noHeap @noUnsafe @noAliasingMutableBorrows module {} Taint Destack systematizes the idea of "taints", "source", and "unsafe" modifiers on expressions and declarations using its taint system: - @taint("tag") marks a value as carrying some domain, @untaint("tag") unmarks it as no longer carrying that domain. - @source("domain") marks an operation that produces some domain, @sink("domain") marks an operation that receives some domain. - @unsafe marks an operation that is unsafe to call, @safe marks an operation that is safe to call. @unsafe declare function read(pointer: *T): ^T; @safe function get(items: Slice, index: usize): T { if (index >= items.length) { panic("index out of bounds"); } return items.unsafeGet(index); } Derive Similar to Rust, Destack supports @derive providers for extending annotated declarations at compile time during the macro expansion phase. Unlike in Rust, a derive provider is just a nominal decorator that happens to implement the Macro interface, and derive-like macros do not need to be implemented in a different package. @derive(Clone, Debug) struct User { id: UserId; name: string; } @derive(Tagged({ case: "UpperCamelCase" })) newtype Shape = | { kind: "rectangle"; width: int32; height: int32 } | { kind: "circle"; radius: int32 }; Destack supports all the common capability-like derives one would expect from a systems-y language, with the notable addition of Serialize, Deserialize, and Tagged: | Derive | Library identity | Applies to | Explicit failure | |--------|------------------|------------|------------------| | Copy | destack:memory.Copy | nominal value types whose fields are all copyable | field or representation is not copyable | | Clone | destack:memory.Clone | nominal value types whose fields are cloneable | field is not cloneable | | Default | destack:memory.Default | nominal value types whose fields have defaults | field has no default | | Debug | destack:ops.Debug | nominal value types | field is not debug-formatable | | PartialEqual | destack:ops.PartialEqual | nominal value types | field is not partially comparable for equality | | Equal | destack:ops.Equal | nominal value types | field does not have total equality | | PartialCompare | destack:ops.PartialCompare | nominal value types | field is not partially orderable | | Compare | destack:ops.Compare | nominal value types | field is not totally orderable | | Hash | destack:ops.Hash | nominal value types | field is not hashable | | Serialize | destack:serde.Serialize | nominal value types | field cannot be serialized by the selected serializer | | Deserialize | destack:serde.Deserialize | nominal value types | field cannot be deserialized by the selected deserializer | | Tagged | destack:decorator.Tagged | discriminated newtype unions | declaration is not a supported tagged union | Also unlike Rust, Destack's derive supports automatic globally configured (and module/target/..-overridable) derives that are applied by default without explicit derive annotation whenever possible. This is very convenient since most types do in fact want all the same basic well known derives, but we can also trivially disable this globally, or override it per-item with an empty @derive(). Static If Destack also supports a special intrinsic @if decorator that gates the inclusion of certain nodes based on a static term (roughly like #[cfg(attr)] in Rust). The static term must be based on static data (like import.meta), and when the condition evaluates to false, the annotated thing is ignored and removed from checking and output. interface FileSystem { open(path: string): Result; @if(import.meta.platform != "windows") chmod(path: string, mode: uint16): Result; @if(import.meta.platform == "windows") setAttributes(path: string, attrs: WindowsFileAttributes): Result; } Static ifs may annotate any meaningfully "removable" source contribution: | Context | Nodes | Static inputs | | --- | --- | --- | | Module level | imports, re-exports, top-level declarations, module { ... } decorators | profile, module metadata, literals | | Declaration members | struct fields, class / interface / extension members, enum variants | profile, module metadata, literals, closed constants | | Expression positions | statements, match cases, call / tree / generic arguments, tuple elements, object and type literal fields | profile, module metadata, literals, closed constants | When a choice depends on generic parameters, static terms don't work (since they must be evaluated ahead of inference), and we can use type algebra and regular comptime gating instead for concrete specialisation. Module Destack modules can contain (up to) one module { ... } declaration block, which carries decorators that apply to the whole source module. @noHeap module {} Module metadata such as the role, labels, product, active derives, or tree builder comes from the compiler / target / profile configuration and is read through import.meta. Globals TypeScript supports ambient global typings, which were designed for typing the "magic" global objects provided by embedders, but it has no way to contribute value globals in userland. Destack supports "real" value global { ... } declarations to define such globals that can then be automatically included everywhere by (explicit) reference in the compiler / target configuration. The active set of modules to consider for global declarations is configured via the globals field in the compiler / target configuration. // browser-globals.ds global { // just omit the `declare`! const window: Window = runtime.browser.window(); const document: Document = runtime.browser.document(); } A global block may also re-export named bindings from another module into the ambient globals. Indeed, that is the same mechanism the well known Destack prelude uses to inject intrinsic language items: global { export { Add, Subtract } from "destack:ops"; } Comptime Inspired by modern languages like Zig and Jai, Destack supports compile-time evaluation with comptime expressions: ordinary code to be evaluated by the compiler, during compile time, and the results baked into the emitted artifact. const LOOKUP_TABLE: uint8[] = comptime { let table: uint8[] = []; for (let i = 0; i < 256; i++) { table.push(computeCRC(i)); } table }; Functions do not need to declare themselves as either "comptime" or "runtime": the same function can run at compile time when all inputs are static, and at runtime when some input is only known at runtime: function factorial(n: int): int { if (n <= 1) { return 1; } else { return n * factorial(n - 1); } } const COMPTIME_CONST = comptime factorial(10); // compile time COMPTIME_CONST satisfies int; const RUNTIME_CONST = factorial(getUserInput()); // runtime (in this case, at module initialization time) RUNTIME_CONST satisfies int; When runtime execution would be meaningless or unsafe, a function can be declared comptime function to declare that a function has no runtime callable form, but otherwise uses normal function syntax. (This is in some way the opposite of the usual constexpr based keyword, that is, Destack functions are evaluated at comptime by usage, and can be marked comptime to force compile-time evaluation.) comptime function fieldOffset(name: string): usize { // inspect `T` at compile time } const offset = comptime fieldOffset("name"); The evaluation scope for each comptime expression is isolated to its declaration site, and the only way to get a value "out" is to use comptime as an expression - no reaching into statics or globals allowed. (Locals inside the expression may of course be mutated.) const WIDTH = comptime { let width = 4; width *= 2; width }; let counter = 0; comptime { counter += 1; // ERROR: outer mutation } Comptime blocks can also appear as members on object-like types for static checks, where they run in the static environment of the declaration or instantiation that they appear in post-inference, and they can access the same static terms as @if. struct Buffer { comptime { assert(size > 0 && size <= 65536); } data: [uint8; size]; } Because comptime expressions are late-evaluated expressions, comptime conditions type check like ordinary conditions. Both branches are analyzed, and the expression type is still the joined branch type. The compiler may eliminate the untaken branch before final lowering when the condition is computed from static inputs: function isPowerOfTwo(value: uint): boolean { if (value == 0) { return false; } let n = value; while (n > 1) { if (n % 2 != 0) { return false; } n /= 2; } return true; } function blockCost(): int32 { if (comptime isPowerOfTwo(Width)) { return 1; } else { return 2; } } function blockMultiply(a: int32, b: int32): int32 { comptime { assert(isPowerOfTwo(Width)); } } Of course, comptime results must also be lowerable into the target artifact. Plain data such as numbers, strings, arrays, tuples, objects, structs, and enums are all fine, but dynamic runtime resources like pointers and handles and such don't work because we can't meaningfully serialize them. Dynamic Code Generating and evaluating arbitrary code is supported via eval at compile-time by passing a string computed at comptime: import * as dir from "destack:reflect/dir"; const source = comptime renderParser(grammar); const parser = comptime eval(source); The source passed to eval must itself be available to comptime evaluation. Generated code is parsed and typechecked as .ds, attached to the same module graph as a virtual source file, and tracked for diagnostics and artifact caching. Macros Destack is statically typed and compiled, but supports macros as decorators backed by comptime execution and a bounded module-editing context. As an example, consider a memoize decorator that turns a function into a cached ("memoized") version of itself that stores results in a cache to avoid recomputation on equal arguments. newtype memoize = { capacity?: uint; }; @memoize({ capacity: 1024 }) function load(id: UserId): Result { } As explained in Decorators, memoize by itself is just an inert annotation and it only receives behavior by implementing Macro. The Macro system is based on four rules: 1. Macro expansion is recursive and runs until there is nothing more to expand (or we encounter an error). 2. Macros run in two phases during compilation: expand may contribute new symbols before final inference, while materialize fills in implementation details with full type information. 3. Macros interact with their containing module through phase-specific context methods (resolve, ensureImport, add, ensureDeclaration, addChild, replaceTarget, renameTarget, removeTarget). 4. Macro invocations are exclusively triggered by decorators implementing Macro; compiler-owned derives are a separate expansion path. | Operation | Example | Meaning | |-----------|---------|---------| | resolve | context.resolve("ROUTES") | resolve a visible symbol in the current scope | | ensureImport | context.ensureImport("destack:collections", "Map") | ensure an import used by generated code | | add | context.add(declaration) | add a generated declaration to the current scope | | ensureDeclaration | context.ensureDeclaration("RouteDefinition", () => declaration) | ensure a generated helper declaration exists | | addChild | context.addChild(member) | add a generated child to the target declaration | | replaceTarget | context.replaceTarget(declaration) | redirect the target symbol to a generated declaration | | renameTarget | context.renameTarget(name) | keep the target declaration but change its visible name | | removeTarget | context.removeTarget() | remove the target symbol from the visible declaration set | Most basic wrapper-shaped decorators are just rename plus add. type MemoizeState = { innerName: string; capacity: uint; }; extension of memoize implements Macro { static expand( target: FunctionDeclaration, context: ExpansionContext, config: this, ): MemoizeState { const innerName = `${context.name}Inner`; const wrapper = comptime eval(ds` function ${context.name}(id: UserId): Result { // placeholder } `); context.renameTarget(innerName); context.add(wrapper); return { innerName, capacity: config.capacity ?? 256, }; } static materialize( target: FunctionDeclaration, context: MaterializationContext, config: this, state: MemoizeState, ): void { const implementation = comptime eval(ds` function ${context.name}(id: UserId): Result { const cached = cache.get(id); if (cached != undefined) { return cached; } const user = ${state.innerName}(id)?; cache.set(id, user, ${state.capacity}); return Result.ok(user); } `); context.replaceTarget(implementation); } }