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.
1const result = if (condition) {2 computeA()3} else if (condition) {4 computeB()5} else {6 computeB()7};8 9function add(a: int, b: int): int {10 a + b // implicit return11}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:
1const result = if (let value! = maybe) {2 value3} else {4 05};6 7if (let (x, y) = point) {8 print(x + y);9}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.
1const user = do {2 const record = loadUser(id)?;3 User.fromRecord(record)4};Closures
Closures in Destack work essentially like TypeScript's closures, capturing the surrounding lexical environment and preserving lexical this around a generic Function<Parameters, Return>.
"Arrow function types" are syntax sugar for that form, so (message: string) => Result<void, IOError> is the same type as Function<(string,), Result<void, IOError>>.
1let count = 0;2 3const next = () => {4 count += 1;5 return count;6};7next satisfies () => number;8next satisfies Function<(), number>;9 10const 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:
1function apply<F: (int32) => int32>(callback: F, value: int32): int32 {2 return callback(value);3}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:
1let count = 0;2 3@capture("borrow")4let borrowed: &Function<(), int32> = () => count;5 6let value = 0;7 8@capture("move")9let owned: ^Function<(), int32> = () => value;10 11let state = 0;12 13let 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:
1class Client {2 prefix: string;3 4 make(socket: Socket, logger: Logger): (message: string) => Result<void, IOError> {5 @capture({6 default: "copy",7 socket: "move",8 logger: "borrow",9 this: "borrow",10 })11 return (message) => {12 logger.info("sending");13 return socket.write(`${this.prefix}: ${message}`);14 };15 }16}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<TResume, TYield, TReturn> |
one-shot owned continuation, Drop cancels it |
Promise<T> |
Worker-local async result object |
Generator<Y, R, N> |
Worker-local suspended generator |
AsyncGenerator<Y, R, N> |
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.
1type User = { name: string; };2 3async function read(user: User): Promise<string> {4 const name = user.name;5 await tick();6 return name; // valid as before7}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<T> 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):
1async function crawl(seeds: [Url]): Promise<Report> {2 await using scope = TaskScope.open();3 4 const pages = seeds.map((seed) => scope.spawn(() => fetch(seed)));5 const results = await Promise.all(pages);6 7 return Report.from(results);8} // the scope cannot exit while children are pending; pending children are cancelled on unwindCancelling 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:
1async function refresh(cache: Cache): Promise<void> {2 fetchAndStore(cache); // ERROR: floating promise, await it or hand it to a scope3}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 |
| 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:
1match (result /* Result<T, E> */) {2 Ok { value } => process(value)3 Err { error } if (isRetryable(error)) => retry()4 Err { error } => fail(error)5}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.
1declare const point: Point;2match (point) {3 Point { x: 0, y: 0 } => "origin"4 Point { x, y } => `at ${x}, ${y}` // irrefutable if point: Point5}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.
1declare const point: Point;2match (point) {3 Point { x: 0, y } => "vertical"4 Point { x, y: 0 } => "horizontal"5 _ => "neither" // required fallback6}7 8declare const result: Result<int32, string>;9let Result.Ok(value)! = result else {10 return Result.err("missing value");11};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.
1class User {2 name: string = "";3 4 get displayName(): string {5 return this.name;6 }7}8 9declare const user: User;10 11match (user) {12 User { name } => name13}The patterns match only real fields, not getters or setters:
1match (user) {2 User { displayName } => displayName // ERROR: getter3}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 valuefor object-shaped values.key in valuethroughHas<K>for custom containers.instanceoffor classes.value is Tfor 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:
1struct User {2 name: string;3}4 5function label(value: User | string): string {6 if (value is User) {7 return value.name;8 } else {9 return value;10 }11}Like other guards, value is T returns boolean and narrows the branch:
- When
true: narrows to the part of its current type that can beT. - When
false: narrows away the covered part (when that can be represented). For union values, the guard test sees "through" the union payload:
1const value: string | int32 = 1;2 3if (value is string) {4 value satisfies string;5} else {6 value satisfies int32;7}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.
1const line = loop {2 const input = readInput();3 if (input == "quit") {4 break "done";5 }6 process(input);7};8 9let status = outer: loop {10 break outer: "done";11};12status 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: <expr> or just break <expr> (where the <expr> 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:
usingacceptsDispose | null | undefined.await usingacceptsAsyncDispose | Dispose | null | undefined, and falls back to synchronous disposal when the resource only implementsDispose.nullandundefinedare 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.
1{2 using input = openFile(inputPath),3 output = openFile(outputPath);4 5 copy(input, output);6} // output is disposed, then input is disposed7 8async function runQuery(sql: string): Result<Row[], DatabaseError> {9 await using connection = await pool.connect();10 11 return await connection.query(sql);12} // connection is disposed and awaited13 14for (using file of files) {15 process(file);16} // file is disposed after each iterationOperators
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<T> |
- |
a - b |
Subtract<T> |
* |
a * b |
Multiply<T> |
/ |
a / b |
Divide<T> |
% |
a % b |
Remainder<T> |
** |
a ** b |
Power<T> |
+ |
+a |
Plus |
- |
-a |
Negate |
& |
a & b |
And<T> |
| |
a | b |
Or<T> |
^ |
a ^ b |
Xor<T> |
~ |
~a |
Not |
<< |
a << b |
ShiftLeft<T> |
>> |
a >> b |
ShiftRight<T> |
>>> |
a >>> b |
ShiftRightUnsigned<T> |
==, != |
a == b |
PartialEqual<T> |
<, <=, >, >= |
a < b |
Compare<T> or PartialCompare<T> |
in |
key in value |
Has<K> for custom containers |
[] |
a[i] |
Index<I> |
[] = |
a[i] = v |
IndexSet<I, V> |
* |
*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 throughPartialEqual<T>.equal Equal<T>is a stronger marker interface based onPartialEqual<T>
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<T> where ordering may be undefined (e.g., floats), and Compare<T> 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<A> 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.
1struct Box<T> {2 ptr: ^T;3}4 5extension<T, comptime A: Access = "readonly"> of Box<T> implements Dereference<A> {6 type Output = T;7 8 dereference(): WithAccess<&T, A> {9 &this.ptr10 }11}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:
1declare const a: uint8;2declare const b: uint8;3 4a.wrappingAdd(b) satisfies uint8; // modular arithmetic5a.saturatingAdd(b) satisfies uint8; // clamps at the bounds6a.checkedAdd(b) satisfies uint8 | undefined; // detects overflow as a value7Wrapping(a) + Wrapping(b); // wrapping by typeConversions 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:
1declare const wide: int32;2 3const a: int64 = wide as int64; // OK: lossless widening4const b: uint8 = wide as uint8; // ERROR: lossy conversion5const c = wide.truncate<uint8>(); // explicit: keep the low bits6const d = wide.saturate<uint8>(); // explicit: clamp into range7const e = wide.tryInto<uint8>(); // explicit: uint8 | undefinedSimilarly, 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<T>, whose startBound() and endBound() methods return Bound<T>:
| Expression | Type | Meaning |
|---|---|---|
start..end |
Range<T> |
Include start, exclude end |
start..=end |
RangeInclusive<T> |
Include both bounds |
start.. |
RangeFrom<T> |
Include start, no end bound |
..end |
RangeTo<T> |
No start bound, exclude end |
..=end |
RangeToInclusive<T> |
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:
1let values: Slice<int32> = [1, 2, 3, 4, 5];2 3(&values)[1..4] satisfies &Slice<int32>;4(&readonly values)[1..4] satisfies &readonly Slice<int32>;5(&exclusive values)[1..4] satisfies &exclusive Slice<int32>;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:
1function parse(input: string): int32 {2 return parseInt(input);3}4 5function parse(input: int32): int32 {6 // legal, actually different implementation!7 return input;8}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.
1newtype interface Add<T = this> {2 type Output;3 4 add(other: T): this.Output;5}6 7extension of Vector2 implements Add<Vector2> {8 type Output = Vector2;9 10 add(other: Vector2): this.Output {11 Vector2({ x: this.x + other.x, y: this.y + other.y })12 }13}14 15extension of Vector2 implements Add<float32> {16 type Output = Vector2;17 18 add(other: float32): this.Output {19 Vector2({ x: this.x + other, y: this.y + other })20 }21}22 23const moved = position + offset; // Add<Vector2>24const padded = position + 1.0; // Add<float32>25 26moved satisfies Vector2;27padded satisfies Vector2;28 291.0 + position; // requires Add<Vector2> on float32Interfaces
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<T>.
1interface PointLike {2 x: int32;3 y: int32;4}5 6struct Point implements PointLike {7 y: int32;8 x: int32;9}10 11function lengthSquared(point: PointLike): int32 {12 point.x * point.x + point.y * point.y13}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).
1interface PointLike {2 readonly x: int32 | float32;3}4 5struct Point {6 x: int32;7}8 9const point: PointLike = Point { x: 1 };10point.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<K, V>) 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).
1interface Bag<T> {2 readonly [key: string]: T;3}4 5// finite object view6const counts: Bag<int32> = { apples: 3, oranges: 2 };7 8function read<T>(bag: Bag<T>, key: string): T | undefined {9 bag[key]10}11 12read(counts, "apples") satisfies int32 | undefined;13 14// custom indexed containers use operator interfaces instead15const dynamicCounts = new Map<string, int32>();16dynamicCounts.set("apples", 3);17dynamicCounts.has("apples") satisfies boolean;18dynamicCounts satisfies Index<string>;19dynamicCounts satisfies IndexSet<string, int32>;20dynamicCounts satisfies Has<string>;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.
1struct TcpStream {2 write(chunk: [uint8]): Result<usize, IOError> {}3}4 5struct MemoryBuffer {6 write(chunk: [uint8]): Result<usize, never> {}7}8 9function writeAll(sink: TcpStream | MemoryBuffer, chunk: [uint8]) {10 const written = sink.write(chunk);11 written satisfies Result<usize, IOError> | Result<usize, never>;12}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<Vector2> 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<T, E>, 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<T> never rejects, because rejection is just an asynchronous exception.
Async failure travels as AsyncResult<T, E>, 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:
1newtype interface Error {2 display(): string;3 4 source(): Dynamic<Error> | undefined {5 undefined6 }7}Result
Destack provides Result<T, E> as the primary error handling mechanism:
1export struct Ok<T> {2 kind: "Ok" = "Ok";3 value: T;4}5 6export struct Err<E> {7 kind: "Err" = "Err";8 error: E;9}10 11export newtype Result<T, E> = Ok<T> | Err<E>;We typically construct results through Result.ok(value) and Result.err(error).
The variants are ordinary nominal data, so pattern matching works directly:
1declare function parseInteger(raw: string): Result<int32, ParseError>;2 3function parsePort(raw: string): Result<uint16, ParseError> {4 const value = parseInteger(raw);5 if (value < 0 || value > 65535) {6 return Result.err(ParseError(`port out of range: ${raw}`));7 } else {8 return Result.ok(value as uint16);9 }10}11 12match (parsePort(input)) {13 Ok { value } => connect(value)14 Err { error } => report(error)15}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<Result<T, E>>.
1export newtype AsyncResult<T, E> = Promise<Result<T, E>>;2 3declare function fetchUser(id: UserId): AsyncResult<User, NetworkError>;4 5async function loadProfile(id: UserId): AsyncResult<Profile, NetworkError | DecodeError> {6 const user = await? fetchUser(id); // `await? expr` is sugar for `(await expr)?`7 const profile = decodeProfile(user)?;8 return Result.ok(profile);9}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:
1declare const x: Result<T | null | undefined, E | null | undefined> | null | undefined;2 3// x?4// -> success: T5// -> failure (propagated): E | null | undefinedNullish values on the failure side remain in the failure side. The three operators differ on the failure case:
1x? // success T, failure leaves the expression2x! // success T, failure traps3x ?? y // success T, failure evaluates yThe 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.
1function readConfig(path: string): Result<Config, IOError | ParseError> {2 const text = readFile(path)?;3 const json = parseJson(text)?;4 return Result.ok(Config.from(json));5}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<T, E> ?? F is the non-nullish opened success type joined with the fallback type T | F:
1declare const defaultConfig: Config;2 3declare function loadConfig(): Result<Config, IOError> | null;4const a = loadConfig() ?? defaultConfig;5a satisfies Config;6 7declare function loadMaybeConfig(): Result<Config | null | undefined, IOError | null> | undefined;8const b = loadMaybeConfig() ?? defaultConfig;9b 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:
1declare function loadNested(): Result<Result<Config, ParseError>, IOError>;2 3const c = loadNested() ?? defaultConfig;4c satisfies Result<Config, ParseError> | 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:
1const config = loadConfig()!;2config 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:
1struct TryContinue<T> {2 kind: "continue" = "continue";3 value: T;4}5 6struct TryFailure<F> {7 kind: "failure" = "failure";8 failure: F;9}10 11type TryBranch<T, F> = TryContinue<T> | TryFailure<F>;12 13newtype interface Try {14 type Value;15 type Failure;16 17 static fromValue(value: this.Value): this;18 branch(): TryBranch<this.Value, this.Failure>;19}For Result<T, E>, Ok { value } branches to TryContinue<T> and Err { error } branches to TryFailure<E>.
Try-Catch-Finally
The well known try/catch forms still work with explicit Try propagation, which is quite cool:
1declare function readConfig(path: string): Result<Config, IOError>;2 3try {4 const config = readConfig("config.json")?;5 process(config);6} catch (e) { // e: IOError7 log("failed to read config:", e)8}The example above uses Result, but any type implementing Try can be used:
tryprovides an error propagation context (but does not unwrapResultvalues by itself)- Use
?inside the block to propagateTryfailures 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:
1try {2 let config = readConfig()?; // -> Result<RaConfig, MissingError>3 config satisfies RawConfig;4 5 let config = parseConfig(config)?; // -> Result<Config, FormatError>6 config satisfies Config;7 8 // ... do stuff with config ...9} catch match (failure) { // failure: MissingError | FormatError10 MissingError { path } => Report.wrap(failure, `missing config: ${path}`)11 FormatError { line } => Report.wrap(failure, `bad format on line ${line}`)12}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:
- The panic starts unwinding from the trapping point with a message payload.
- Cleanup runs on the way out -
using/await usingdisposal,finallyarms, andDropglue for owned values - in the usual LIFO order. - The Worker terminates; a parent or supervisor observes the termination and gets the
Panicstruct - message, source location, and stack trace when available - through the regularWorkerAPI and decides what to do (restart, propagate, report). - 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:
1// Wall.ds2<Wall id={1}>3 <Block name="foo" color={Color.RED} />4 <Block name="bar" color={Color.BLUE} />5</Wall>;6 7// Prompt.ds8<Prompt>9 <System>You are a helpful assistant.</System>10 <User>{userMessage}</User>11</Prompt>;12 13// Level.ds14<Level difficulty={3}>15 <Player position={spawn} />16 {enemies.map(e => <Enemy {...e} />)}17</Level>;Unlike in TypeScript, Destack types can participate in custom tree tag behavior by implementing the TreeTag interface, and custom intrinsic tags (lowercase tags like <div>) 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
TreeTaginterface. - 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 @<expr> - 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.
1newtype deprecated = () | (string,);2 3@deprecated("use newAPI instead") // metadata annotation, doesn't do anything4function oldAPI() {5 // ...6}7 8@tracked9@derive(Clone, Debug)10struct User {11 id: UserId;12 name: string;13}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)
1@allow("no-floating-promises", {2 if: import.meta.dev,3 otherwise: "deny",4 reason: "debug telemetry",5})6module {}Restrictions
Relatedly, restrictions may be used to allow or disallow more fundamental reaching language behavior in certain scopes:
1@noHeap2@noUnsafe3@noAliasingMutableBorrows4module {}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.@unsafemarks an operation that is unsafe to call,@safemarks an operation that is safe to call.
1@unsafe2declare function read<T>(pointer: *T): ^T;3 4@safe5function get<T>(items: Slice<T>, index: usize): T {6 if (index >= items.length) {7 panic("index out of bounds");8 }9 10 return items.unsafeGet(index);11}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<Target> interface, and derive-like macros do not need to be implemented in a different package.
1@derive(Clone, Debug)2struct User {3 id: UserId;4 name: string;5}6 7@derive(Tagged({ case: "UpperCamelCase" }))8newtype Shape =9 | { kind: "rectangle"; width: int32; height: int32 }10 | { 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.
1interface FileSystem {2 open(path: string): Result<File, IOError>;3 4 @if(import.meta.platform != "windows")5 chmod(path: string, mode: uint16): Result<void, IOError>;6 7 @if(import.meta.platform == "windows")8 setAttributes(path: string, attrs: WindowsFileAttributes): Result<void, IOError>;9}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.
1@noHeap2module {}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.
1// browser-globals.ds2global { // just omit the `declare`!3 const window: Window = runtime.browser.window();4 const document: Document = runtime.browser.document();5}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:
1global {2 export { Add, Subtract } from "destack:ops";3}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.
1const LOOKUP_TABLE: uint8[] = comptime {2 let table: uint8[] = [];3 for (let i = 0; i < 256; i++) {4 table.push(computeCRC(i));5 }6 table7};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:
1function factorial(n: int): int {2 if (n <= 1) {3 return 1;4 } else {5 return n * factorial(n - 1);6 }7}8 9const COMPTIME_CONST = comptime factorial(10); // compile time10COMPTIME_CONST satisfies int;11 12const RUNTIME_CONST = factorial(getUserInput()); // runtime (in this case, at module initialization time)13RUNTIME_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.)
1comptime function fieldOffset<T>(name: string): usize {2 // inspect `T` at compile time3}4 5const offset = comptime fieldOffset<User>("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.)
1const WIDTH = comptime {2 let width = 4;3 width *= 2;4 width5};6 7let counter = 0;8comptime {9 counter += 1; // ERROR: outer mutation10}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.
1struct Buffer<comptime size: uint> {2 comptime {3 assert(size > 0 && size <= 65536);4 }5 6 data: [uint8; size];7}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:
1function isPowerOfTwo(value: uint): boolean {2 if (value == 0) {3 return false;4 }5 6 let n = value;7 while (n > 1) {8 if (n % 2 != 0) {9 return false;10 }11 n /= 2;12 }13 14 return true;15}16 17function blockCost<comptime Width: uint>(): int32 {18 if (comptime isPowerOfTwo(Width)) {19 return 1;20 } else {21 return 2;22 }23}24 25function blockMultiply<comptime Width: uint>(a: int32, b: int32): int32 {26 comptime {27 assert(isPowerOfTwo(Width));28 }29}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:
1import * as dir from "destack:reflect/dir";2 3const source = comptime renderParser(grammar);4const parser = comptime eval<dir.FunctionDeclaration>(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.
1newtype memoize = {2 capacity?: uint;3};4 5@memoize({ capacity: 1024 })6function load(id: UserId): Result<User, Error> {7}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:
- Macro expansion is recursive and runs until there is nothing more to expand (or we encounter an error).
- Macros run in two phases during compilation:
expandmay contribute new symbols before final inference, whilematerializefills in implementation details with full type information. - Macros interact with their containing module through phase-specific context methods (
resolve,ensureImport,add,ensureDeclaration,addChild,replaceTarget,renameTarget,removeTarget). - 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.
1type MemoizeState = {2 innerName: string;3 capacity: uint;4};5 6extension of memoize implements Macro<FunctionDeclaration, MemoizeState>7{8 static expand(9 target: FunctionDeclaration,10 context: ExpansionContext,11 config: this,12 ): MemoizeState {13 const innerName = `${context.name}Inner`;14 const wrapper = comptime eval<Declaration>(ds`15 function ${context.name}(id: UserId): Result<User, Error> {16 // placeholder17 }18 `);19 20 context.renameTarget(innerName);21 context.add(wrapper);22 23 return {24 innerName,25 capacity: config.capacity ?? 256,26 };27 }28 29 static materialize(30 target: FunctionDeclaration,31 context: MaterializationContext,32 config: this,33 state: MemoizeState,34 ): void {35 const implementation = comptime eval<Declaration>(ds`36 function ${context.name}(id: UserId): Result<User, Error> {37 const cached = cache.get(id);38 if (cached != undefined) {39 return cached;40 }4142 const user = ${state.innerName}(id)?;43 cache.set(id, user, ${state.capacity});44 return Result.ok(user);45 }46 `);47 48 context.replaceTarget(implementation);49 }50}