destack.sh

Memory

TypeScript, like many managed high level languages, does not encode memory "ownership" in its type system: all reference types are implicitly GC-managed on some (local) heap, and all value types are copied by default. That is convenient and often what we want, but sometimes we need to take direct control of memory, whether for better performance, or just to express certain invariants in the code.

Destack supports explicit, optional type modifiers for controlling memory placement and ownership:

  • Placement - where the value is located: ambient by default, explicitly local to one Worker, shared across Workers (or static for constant and frame for activation frames).
  • Ownership - who "owns" the value: managed (T), owned (^T), borrowed (&T, &readonly T, &exclusive T), or raw (*T).

The two axes of ownership and placement compose and commute freely, e.g. shared ^T and ^shared T both mean an owned handle to a value in shared space, and shared &T is a borrow of a shared value. Once structural shapes and constraints have been reified, instantiated, or erased, the plain old T still behaves as the type's default representation, exactly like we're used to from TypeScript: value types are values, object types are managed references, both can be aliased and mutated freely (locally).

Everything in this chapter follows from four rules:

  1. Every value has exactly one owner - for managed values, the owner is the runtime.
  2. A borrow must remain valid: a place cannot move or drop while an overlapping borrow is live, and a borrow cannot outlive the source it came from.
  3. Exclusive borrows are truly exclusive: no overlapping borrow of the same place may be live.
  4. Shared memory must never point into local memory.

Ownership

Ownership determines who keeps a value alive, who is allowed to mutate it, and when and how it is eventually freed. The notion of ownership has many names and forms, but today it is most commonly associated with Rust's explicit ownership system, and that is also the system most similar to Destack. Really, "ownership" just means that each value has an owner and certain rules apply to how we can pass and store values to certain places, depending on which level of mutability and ownership they need.

The usual explanation of "ownership" sounds more complex than it is, especially to developers used to "managed" languages, and especially because Rust tradition (deliberately) unifies "liveness", "exclusivity" and "mutability" while only liveness is required for memory safety. Unlike Rust, Destack supports both multiple mutable borrows (&T) and exclusive mutable borrows (&exclusive T):

Form Meaning Mutable? Exclusive?
T default value form: direct for value types, managed for reference types yes depends on the default form
^T owned value form yes yes (single owner)
&T borrowed access yes no
&readonly T readonly borrowed access no no
&exclusive T exclusive borrowed access yes yes
*T raw pointer yes (unchecked) no (unchecked)

The owner of a value is responsible for keeping it alive, and also for disposing of the owned value when the parent's own lifetime ends. The parent (owner) could be in static storage for global constants, it could be another owned or even managed value, or it could be a call frame in a method (in which case we get a stack allocation).

Ownership.ds
1let user: User = new User();   // managed: the runtime owns it2let owned: ^User = new User(); // owned: this binding owns it, dropped after last use3 4let borrow: &User = &user;                    // mutable borrow of the managed value5let view: &readonly User = &readonly user;    // OK: readonly may overlap the mutable borrow6let claim: &exclusive User = &exclusive user; // ERROR: `borrow` and `view` are still live7let pointer: *User = &user;                   // OK: raw pointers are inert and unchecked

Each ownership form also has a corresponding normalized representation in our little "type algebra", which means we get to do regular TypeScript-style type space logic, conditionals and remapping (including for lifetimes!).

Borrowing

To ensure memory safety even in unmanaged land, Destack follows the Rust idea of using ownership rules to ensure that borrowing a reference &T remains valid - that is, T must remain alive (must not be deallocated) while any &T is active. Much more so than in Rust, Destack infers lifetimes for borrowed values even with complex control flow, so most of the time all lifetimes are inferred correctly and we don't need to think too much.

Unlike in Rust, in Destack, mutability is decoupled from borrowing: we can have multiple mutable borrows &T and readonly borrows &readonly T of the same T at the same time, as long as there is no concurrent &exclusive T borrow (which mirrors Rust's &mut T). Importantly, this is still memory safe because all operations that may invalidate a borrow require &exclusive T access.

Form Access
&readonly T may overlap, cannot mutate through the borrow
&T may overlap, can mutate through the borrow
&exclusive T cannot overlap another borrow of the same place, can mutate through the borrow

Borrow checking is just rule 2 applied per "access path", so disjoint fields can be borrowed independently when the compiler can prove they do not overlap.

Borrowing.ds
1struct Point {2    x: int32;3    y: int32;4}5 6class User {7    name: string = "";8}9 10let user: User = new User();11 12// `&user.name` borrows through the managed `User` handle13let name = &user.name;14 15// `name` is borrowed access into managed storage16name satisfies &string;17 18let point = ^Point { x: 1, y: 2 };19 20// `&readonly point.x` borrows from owned storage without moving `point`21let readX = &readonly point.x;22 23// `&point.x` *can* overlap with readonly borrowed access24let writeX = &point.x;25*writeX = 3;26 27// `&point.y` mutably borrows a disjoint field28let writeY = &point.y;29*writeY = 3;30 31// the readonly borrow is still valid here32readX satisfies &readonly int32;33 34// `&exclusive point.x` is allowed after the overlapping borrows are no longer live35let exclusiveX = &exclusive point.x;36*exclusiveX = 4;

Stability

Allowing multiple live mutable borrows is memory safe only because every operation that may invalidate another live borrow requires exclusivity (via &exclusive). Writing through a non-exclusive borrow is therefore allowed only when that place is overwrite-stable: the old value requires no destruction (no Drop), and the new bytes mean what the old bytes meant (one fixed layout, no variant tag), both of which scalar and managed-reference fields trivially satisfy. Everything else requires &exclusive: overwriting a variant reinterprets the payload under a live interior borrow, overwriting an owning value frees memory a borrow may still target, and so on.

Stability.ds
1struct Frame {2    pixels: ^Buffer; // owned interior storage3}4 5let frame: ^Frame = Frame { pixels: Buffer.open() };6 7let pixels = &frame.pixels; // interior borrow into the owned buffer8let alias = &frame;         // non-exclusive borrows may overlap9 10*alias = Frame { pixels: Buffer.open() }; // ERROR: overwrite drops the old buffer under `pixels`

It should be noted that as with the rest of borrowing and ownership, regular managed land needs to know about exactly zero of this because managed handles alias freely and mutably. Writes through one managed handle can at most result in stale reads through another (a "borrow" into an array taken before it grew still reads the old buffer), which is just ordinary TypeScript aliasing, and not really a memory safety problem in itself.

Rooting

Borrows into managed storage stay valid via something we call "automatic rooting": the compiler remembers every managed handle a borrow needs via a hidden frame slot that keeps it alive until the borrow's last use (and the collector scans that slot like any other handle local). This is essentially what we always want anyway, and it lets the GC not worry about interior borrows at all: no write can invalidate a borrow rooted in managed storage since overwriting the path the borrow came through leaves the old object alive.

Rooting.ds
1class Profile {2    name: string;3}4 5class User {6    profile: Profile;7}8 9let name = &user.profile.name;          // roots the `user.profile` handle10user.profile = new Profile("other");    // OK: the old profile stays pinned11print(*name);                           // still reads the borrowed profile

The hidden slot is just a compiler temporary, and borrows of temporaries follow the same extension rules as Rust: a borrow that initializes a binding extends its temporary to the binding's lifetime, and any other temporary lives to the end of the enclosing statement.

Definite Assignment

In general, all binding must be definitely assigned to something before they can be used in any way (read, moved, borrowed, returned, or implicitly dropped):

Definite Assignment.ds
1let value: Buffer;2 3if (enabled) {4    value = Buffer.open();5}6 7process(value); // ERROR: `value` is not assigned on every path

When "maybe" state is explicitly desired, we can represent it with a nullish union (or any other union as needed)_

Definite Assignment.ds
1let value: Buffer | undefined;2 3if (enabled) {4    value = Buffer.open();5}6 7if (value != undefined) {8    process(value);9}

Lifetimes

Lifetimes tie a borrow to its source, and in Destack they are just generics: <comptime L: Lifetime> parameters on Borrowed<T, L>, available to all the regular TypeScript-style type algebra and inference (including flow typing and narrowing). Like Access, Space, and Place, Lifetime is a kind of static value rather than a type, which is why parameters over these forms always carry the comptime modifier. In practice they are spelled out in exactly one place - declare signatures - and inferred everywhere else, including from function bodies.

Lifetimes.ds
1function read(user: &User): &string {2    return &user.name;3}4 5function first<T>(items: &[T]): &T {6    return &items[0];7}

Elided borrowed forms get hidden generic lifetime parameters in all declarations, not just in function signatures.

Lifetimes.ds
1// elided form2struct WorldView {3    engine: &Engine;4    assets: &AssetStore;5}6 7// explicit form8struct WorldView<comptime L1: Lifetime, comptime L2: Lifetime> {9    engine: Borrowed<Engine, L1>;10    assets: Borrowed<AssetStore, L2>;11}

Because lifetimes are part of type inference, and type inference also analyzes method bodies, lifetime inference also derives from function bodies. Each elided borrow in the signature induces its own hidden lifetime parameter first, and the body then solves the return lifetime:

Lifetimes.ds
1// elided form2function first(a: &Node, b: &Node): &Node {3    return a;4}5 6// explicit form7function first<comptime L1: Lifetime, comptime L2: Lifetime>(8    a: Borrowed<Node, L1>,9    b: Borrowed<Node, L2>,10): Borrowed<Node, L1> {11    return a;12}13 14// elided form15function choose(a: &Node, b: &Node, flag: boolean): &Node {16    return flag ? a : b;17}18 19// explicit form20function choose<comptime L1: Lifetime, comptime L2: Lifetime>(21    a: Borrowed<Node, L1>,22    b: Borrowed<Node, L2>,23    flag: boolean,24): Borrowed<Node, L1 | L2> {25    return flag ? a : b;26}

Because declare functions do not have bodies, it follows that declaration-only APIs must spell out lifetime relationships explicitly.

Lifetimes.ds
1// rejected2declare function only(value: &Node): &Node;3 4// accepted5declare function only<comptime L: Lifetime>(value: Borrowed<Node, L>): Borrowed<Node, L>;6 7// rejected8declare function choose(9    a: &Node,10    b: &Node,11): &Node;12 13// accepted14declare function choose<comptime L1: Lifetime, comptime L2: Lifetime>(15    a: Borrowed<Node, L1>,16    b: Borrowed<Node, L2>,17): Borrowed<Node, L1 | L2>;

Taken together, the mechanisms of elision and inference for lifetimes let us implement the vast majority of low level ownership patterns without having to specify lifetimes to the compiler explicitly (yay). It should also be noted that one consequence of this body-derived inference is that a body change can change an inferred public signature, but that is just the tradeoff here.

Suspension

As discussed in Continuations, values and objects in "managed land" work exactly like in regular TypeScript and can be referenced and mutated freely wherever. When borrowing across suspension points (like yield or await), the compiler must still guarantee that the core safety rules are upheld. And because multiple continuations may be active concurrently (even if not in parallel), borrows that must be owned by the current frame to cross suspension points - in other words, borrows coming from managed values must not cross suspension points.

It follows that the same body is fine or rejected purely based on where the borrowed value lives:

Suspension.ds
1// owned by the frame: the borrow may cross suspension2async function readOwned(user: ^User): Promise<string> {3    const name = &readonly user.name;4    await tick();5    return name.clone();6}7 8// borrowed parameter: also fine, the caller proves the source is owned or static9async function readBorrowed(user: &User): Promise<string> {10    const name = &readonly user.name;11    await tick();12    return name.clone();13}14 15// managed: rejected, another continuation could touch `user` while we are parked16async function readManaged(user: User): Promise<string> {17    const name = &readonly user.name;18    await tick(); // ERROR: `name` borrows managed storage across suspension19    return name.clone();20}

One load-bearing guarantee underneath all of this: suspension points are always lexically explicit (await and yield). There is no hidden suspension - no implicit awaits, no preemption points, no suspending allocators - so "does this borrow cross suspension" can always be answered by looking at the code (and declaration-only APIs need no extra annotations, since asyncness is visible in signatures and declare signatures already spell out lifetimes).

Drop

Whenever the lifetime of a value ends and it is deallocated, Destack supports running a Drop finalizer, similar to Rust's Drop. This happens when the compiler inserts a drop for an owned local after its last use, when an owned field is being destroyed, and when the runtime eventually reclaims an unreachable managed allocation. Drop sites are statically known: maybe-present state must be represented as an explicit type like T | undefined, and a place conditionally moved on one branch is an error at the join, so there are no runtime drop flags as such.

Drop.ds
1function run(): void {2    let buffer: ^Buffer = Buffer.open();3    process(&buffer);4    // `buffer` is dropped immediately after its last use5}

The same Drop also works for managed references, where it is run before they are freed:

Drop.ds
1class Image {2    pixels: Unique<[uint8]>;3}4 5let image: Image = new Image();6// when `image` is collected, the GC runs drop glue for `pixels`

Unlike Rust and C++ RAII, Destack models Drop and Dispose / AsyncDispose separately: Drop follows lifetime, while using is lexical. Because of this split, Drop can run eagerly after last use, which is great for memory pressure, but also means Destack's Drop is not the right fit for lexical RAII-style cleanup. In general, Drop should manage memory and memory-shaped cleanup, while using should manage richer resource finalization:

Protocol Purpose Timing
Drop ownership finalization for memory and owned fields deterministic for owned values, GC-timed for managed values
Dispose explicit synchronous resource cleanup lexical using scope exit
AsyncDispose explicit asynchronous resource cleanup lexical await using scope exit

External resources such as files, locks, sockets, transactions, and temporary runtime registrations should use Dispose or AsyncDispose, not Drop. This keeps RAII-style cleanup explicit and predictable even though ordinary TS++ values are "managed" by default:

Drop.ds
1using file = File.open(path)?;2await using connection = await pool.connect();

Low-level code can of course still control finalization explicitly: drop(value) ends ownership immediately, forget(value) intentionally suppresses automatic drop, ManuallyDrop<T> stores a value outside automatic drop handling, and Box<T>.leak() turns one owned allocation into a static borrow.

Allocation

The primary way to construct new values is new, which initializes a T and produces the ownership form required by the destination type - new is really just an initializer that calls the type's constructor. The required destination type decides whether that is managed storage, owned storage, inline frame storage, shared storage, or some lower-level allocation form.

Allocation.ds
1let a: User = new User();   // managed2let b: ^User = new User();  // owned

In some situations it is useful to handle allocation errors directly, and for that Destack supports more direct fallible allocation accessors both via higher level try* methods in standard library types (like Array.tryReserve) and the low level intrinsics (MaybeUninit<T>, Unique<T>, etc.) that they are built on. Regular managed object allocation (new T()) can also be made fallible via the new? operator, which behaves similar in spirit to the regular ? and await? operators, propagating an AllocationError to the containing context:

Allocation.ds
1try {2    let buffer: Block[] = Array.tryWithCapacity(count)?; // fallible library allocation3    let page = new? Page(buffer);                        // fallible managed allocation4    page.fill()?;5 6    return page;7} catch (e: AllocationError) {8    return null;9}

Code can also opt out of managed allocation locally via restrictions:

Allocation.ds
1@noManaged2function processFrame(input: &[Sample]): ^Frame {3    return buildFrame(input);4}5 6@noHeap7function interruptHandler(input: &[Sample]): Frame {8    return buildFrameOnStack(input);9}

Space

The space of a value determines where it is actually located in memory, and since Destack follows web and TypeScript conventions, the Worker-local heap is the default main memory space. Ordinary managed objects, arrays, strings, functions, closures, and module bindings live in local space, and user and library code can almost always just pretend spaces don't even exist.

Space.ds
1struct Request<T> {2    header: Header;3    body: T;4}5 6let localRequest: Request<Body>;          // ambient, default -> Request is local heap7let sharedRequest: shared Request<Body>;  // explicit, shared -> Request is shared heap

In general, type placement is contextual: all types are "ambient" by default, i.e., they come with no inherent placement, and types are only placed wherever their parent is placed until someone either specifies placement explicitly (e.g., local T, shared T) or we reach the module top (whcih is "local"). Specific nominal declarations may also choose an intrinsic placement for themselves, which then forces them into a specific space:

Space.ds
1local newtype interface Awaitable<T> { await(): T }2local class Promise<T> {}3shared class Channel<T> {}4local newtype TaskId = uint64;5 6PlaceOf<Promise<void>> satisfies "local";7PlaceOf<Channel<string>> satisfies "shared";8PlaceOf<TaskId> satisfies "local";

Shared Space

In TypeScript tradition, each "Worker" (in the JS spec generally referred to as "Agent") has its own isolated local heap that cannot be touched by other workers. For sharing memory across workers, TypeScript has the SharedArrayBuffer concept and plain old message passing with postMessage. That works, but is architecturally limited and makes it complex to implement more sophisticated parallelism patterns.

Destack supports an explicit, separate, fully featured shared memory space that is visible to all Workers in the same Runtime via regular references and objects. Basically, shared T is the typed, generalized version of the SharedArrayBuffer idea with the full type system and object graphs at our disposal with the simple rule that local may point into shared, but shared must not point into local memory.

It's important to note that by itself shared placement, like any space placement, is not a synchronization primitive and does not imply atomic access, locking, Sync, or anything like that. That is by design; it's up to userland libraries to require capabilities like Send and Sync for APIs that transfer or publish values for correctness, but shared itself is really only about placement.

Static Space

Because Destack inherits the JS/TS Worker model for isolation, module-scoped constants are owned by each Worker and are not actually process-global as they would be in most other languages. For genuinely shared process-global state, the binding itself can be declared as shared.

Form Binding place Value place Meaning
const world = new World() local local one local module binding and one local value
const world: shared World = new World() local shared one local binding cell holding one shared handle
shared const world: World = new World() shared shared one shared binding cell initialized in shared space
shared const world: shared World = new World() shared shared same runtime meaning, explicit on both axes

Note that marking the binding itself as shared also types the value as shared (as it is illegal to point from shared storage into local storage anyway, this is convenient). Shared bindings are always const, and const means exactly what it means in TypeScript: the binding is not reassignable, while the value behind it mutates freely through its own API.

One constraint follows directly from the Worker model: a shared binding is reachable from every Worker by construction, so it must meet the same bar as any value crossing Workers - the value type must be Sync (see Capabilities). In particular, a bare owned global like shared const world: ^World is rejected: every Worker can reach the binding, so its exclusivity cannot be checked. Global mutable state instead puts a lock in the static and lets the guard be the runtime exclusivity oracle:

Static Space.ds
1shared const world: Mutex<World> = new Mutex(new World());2 3const view = world.lock(); // guard dereferences to &exclusive World

Because module code runs on every Worker, shared bindings are initialized exactly once by the runtime, before any other Worker can observe them.

Conversions

Reference conversions follow directly from the four memory rules, and borrowing from a live place works whenever the requested loan rules hold:

Source Borrow Notes
local managed T &T / &readonly T / &exclusive T exclusive needs no overlapping loan; none may cross suspension
owned ^T &T / &readonly T / &exclusive T owned sources may also cross suspension
shared owned shared ^T shared &T / shared &readonly T / shared &exclusive T owned storage stays unique even in shared space, since the handle itself still has one holder
shared managed T &T / &readonly T mutation routes through Sync APIs and atomic field access (see Synchronization)
shared managed T &exclusive T never: a shared managed handle cannot prove uniqueness

Borrows can weaken freely, but cannot be upgraded (obviously):

From To Notes
&exclusive T &T / &readonly T temporary reborrow that suspends the exclusive loan
&T &readonly T readonly reborrow
managed reference T ^T never: managed ownership cannot become unique ownership
&T T / ^T never: borrowed access does not own the value

Raw pointers convert freely in, and only unsafely out:

Conversions.ds
1let user = new User();2 3let borrow: &User = &user;   // default: a checked borrow4let pointer: *User = &user;  // typed as raw: an inert, unchecked pointer value5let again: &User = pointer;  // ERROR: pointers only reborrow inside @unsafe

Unsafe

Safe Destack code can create and carry raw pointers, because there is nothing directly unsafe about just looking at pointers. Raw pointers are inert: they do not keep storage alive, do not participate in borrow checking, and do not prove exclusivity.

Converting a borrow to a raw pointer is still safe because it does not touch the pointed-to memory:

Unsafe.ds
1let user = new User();2 3let borrow: &User = &user;4let pointer: *User = borrow; // OK: this only creates a raw pointer value

Unsafe begins when code relies on a memory invariant the compiler cannot prove:

Operation Example Safe? Why
create or carry raw pointer values let pointer: *User = &user, pointer == other yes does not touch memory
reinterpret raw pointer values pointer as *uint8, 0x1000 as *uint8 yes makes no validity claim
wrapping address arithmetic wrappingOffset(pointer, 4) yes makes no allocation claim
allocation-relative pointer math offset(pointer, 4), offsetFrom(pointer, origin) no claims same live allocation
access memory through a pointer asReference(pointer), read(pointer), write(pointer, value) no bypasses borrow checking
build typed views from raw storage Slice.fromRaw(pointer, length) no claims a valid region of T
raw bytes and layout tricks copyBytes(dst, src, n), readVolatile(pointer), transmute<T, U>(value) no touches or reinterprets unchecked memory

The compiler rejects unsafe operations, like raw pointer dereferencing, outside explicit @unsafe / @safe contexts.

Algebra

Destack's "memory algebra" is a fancy way of saying that the axes of ownership, access, lifetime, and placement are just types that we can do TypeScript-style algebra and inference with. We can inspect a type's memory form and build a derived form because all the qualified surface forms like readonly T, ^T, &T, *T, local T, and shared T correspond to builtin intrinsic types and rewrite helpers:

Algebra.ds
1/// Automatically managed T, owned by the runtime.2newtype Managed<T> = intrinsic;3/// Owned T (`^T`).4newtype Owned<T> = intrinsic;5/// Borrowed T (`&T`).6newtype Borrowed<T, comptime L: Lifetime, comptime A: Access = "mutable"> = intrinsic;7/// Raw T (`*T`).8newtype Raw<T> = intrinsic;9/// Placed T (`local T` or `shared T`).10newtype Placed<T, P: Place> = intrinsic;

Specifically, all surface sigils and keywords are just compact syntax for those intrinsic forms that commute the way they read:

Algebra.ds
1declare class User { /* ... */ };2declare struct Point { /* ... */ };3 4type NormalUser = User;                        // unqualified, normal default representation5type NormalPoint = Point;                      // unqualified value type, default-owned/direct6type ReadonlyUser = readonly User;             // WithAccess<User, "readonly">7type OwnedUser = ^User;                        // Owned<User>8type OwnedPoint = ^Point;                      // Owned<Point>, reduced to Point9type ReadonlyBorrowedUser = &readonly User;    // Borrowed<User, L, "readonly">10type BorrowedUser = &User;                     // Borrowed<User, L, "mutable">11type ExclusiveBorrowedUser = &exclusive User;  // Borrowed<User, L, "exclusive">12type RawUser = *User;                          // Raw<User>13type LocalUser = local User;                   // Placed<User, "local">14type SharedUser = shared User;                 // Placed<User, "shared">15type LocalOwnedUser = local ^User;             // Placed<Owned<User>, "local">16type SharedOwnedUser = shared ^User;           // Placed<Owned<User>, "shared">17type OwnedSharedUser = ^shared User;           // Owned<Placed<User, "shared">>

It follows that because forms compose, owning a borrow is different from borrowing an owner:

Algebra.ds
1Owned<Borrowed<User, L>> // owns a borrow value2Borrowed<Owned<User>, L> // borrows an owned value

Destack also provides builtin accessors to inspect composed forms, e.g., PayloadOf<T> removes one outer form, while BaseOf<T> removes all transparent memory forms:

Algebra.ds
1BaseOf<shared ^User> satisfies User;2PayloadOf<Owned<Borrowed<User, L>>> satisfies Borrowed<User, L>;3OwnershipOf<^User> satisfies "owned";4OwnershipOf<Point> satisfies "owned";5OwnershipOf<User> satisfies "managed";6OwnershipOf<Owned<Borrowed<User, L>>> satisfies "owned";7OwnershipOf<Borrowed<Owned<User>, L>> satisfies "borrowed";8OwnershipOr<User, "managed"> satisfies "managed";9AccessOf<User> satisfies "mutable";10AccessOf<readonly User> satisfies "readonly";11AccessOf<^readonly User> satisfies "readonly";12AccessOf<&exclusive User> satisfies "exclusive";

Space represents one builtin concrete space while Place means either a concrete Space or "ambient", and ambient placement follows the containing context until a final layout is required:

Algebra.ds
1PlaceOf<User> satisfies "ambient";2SpaceOf<^User> satisfies never;3PlaceIn<^User, "shared"> satisfies "shared";4 5PlaceOf<local User> satisfies "local";6SpaceOf<local User> satisfies "local";7PlaceIn<local User, "shared"> satisfies "local";8 9PlaceOf<shared User> satisfies "shared";10SpaceOf<shared User> satisfies "shared";11PlaceIn<shared User, "local"> satisfies "shared";12 13PlaceOf<User | local User | shared User> satisfies "ambient" | "local" | "shared";14SpaceOf<User | local User | shared User> satisfies "local" | "shared";15PlaceIn<User | local User | shared User, "local"> satisfies "local" | "shared";16PlaceIn<User | local User | shared User, "shared"> satisfies "local" | "shared";

Predicates with Is* are convenience wrappers around those same accessors:

Algebra.ds
1IsOwned<^User> satisfies true;2IsBorrowed<&User> satisfies true;3IsShared<shared User> satisfies true;4IsShared<^User> satisfies false;5IsSharedIn<^User, "shared"> satisfies true;

Rewriting helpers preserve the rest of the type instead of rebuilding from a stripped base type:

Algebra.ds
1WithSpace<^User, "shared"> satisfies Placed<^User, "shared">;2WithOwnership<shared User, "owned"> satisfies Owned<shared User>;3WithPlace<shared User, "ambient"> satisfies Placed<User, "ambient">;4WithAccess<User, "readonly"> satisfies readonly User;5WithAccess<^User, "readonly"> satisfies ^readonly User;6WithAccess<&User, "exclusive"> satisfies &exclusive User;

Because memory qualification is just ordinary type-level computation, userland code can introspect and rewrite ownership and placement using the same type system we already use for all other types. Inside a type declaration, this in type or static position also carries the current instantiated form of that type to query against with the *Of and Is* family.

Algebra.ds
1struct Buffer<T> {2    lock: PlaceOf<this> == "shared" ? Mutex : ();3    value: T;4}5 6declare const localBuffer: Buffer<string>;7declare const sharedBuffer: shared Buffer<string>;8 9sharedBuffer.lock satisfies Mutex;10localBuffer.lock satisfies ();11PlaceOf<typeof localBuffer> satisfies "ambient";12PlaceOf<typeof sharedBuffer> satisfies "shared";

Polymorphism

Since ownership, access, lifetime, and placement are all reified as memory form types, contracts and implementors get to be polymorphic and (somewhat) conditional over their ownership, space, and access, even on the receiver type. That lets types expose one natural operation when only the projected form changes, and separate operations when the semantics actually differ. The caller chooses the level of control by writing the expression / providing the type they mean:

Polymorphism.ds
1process(user);            // managed/default value2process(&readonly user);  // readonly borrowed access3process(&user);           // mutable borrowed access4process(&exclusive user); // exclusive borrowed access5process(^user);           // owned value

Dispatch resolution uses the actual form during overload resolution, so container interfaces like Iterable<T> can support ordinary TypeScript iteration and borrowed iteration without adding Rust-style method family explosion:

Polymorphism.ds
1declare type Point = { x: number; y: number };2declare const points: Array<Point>;3 4// ordinary "managed" iteration5for (const point of points) {6    point satisfies Point;7}8 9// readonly borrowed iteration10for (const point of &readonly points) {11    point satisfies &readonly Point;12}13 14// mutable borrowed iteration15for (const point of &points) {16    point satisfies &Point;17}18 19// exclusive borrowed iteration20for (const point of &exclusive points) {21    point satisfies &exclusive Point;22}23 24// moved iteration25for (const point of ^points) {26    point satisfies Point;27}

Capabilities

Inspired by many other systems-y languages, Destack encodes synchronization and memory primitives as trait-like interfaces like Copy, Clone, Send, and Sync.

Capability Meaning
Copy Value can be duplicated implicitly without changing ownership responsibilities.
Clone Code can explicitly create another value, possibly by running code or allocating.
Send Value can cross a Worker boundary.
Sync References to shared values can be used concurrently through the type's own API.

Like Rust's auto traits, Send and Sync are derived structurally by default (a type is Send when all of its fields are) and can be opted out of explicitly. Implementing Send or Sync manually - against the structural evidence, as interior-mutability primitives must - is an @unsafe claim like any other unchecked memory assertion. For borrows and handles, the rules follow per memory form:

Form Crosses Workers when
T (local managed) never, local handles stay on their Worker
shared T T: Sync for aliased access
^T T: Send
&readonly T T: Sync, and the source is owned or static
&exclusive T T: Send, and the source is owned or static
&T never, aliased mutability
shared static binding requires T: Sync outright, the binding reaches every Worker by construction

The &T row is the important one: the aliased-mutable middle of the borrow ladder is only safe because a Worker is a single-threaded execution domain, and so it must never cross a Worker boundary. Also, note again shared T means T lives in shared space; it does not make T automatically Sync by itself. Userland APIs such as channels, Worker pools, atomics, locks, and actors can require Send or Sync when they need those stronger guarantees, very similar to Rust or even Swift.

Synchronization

The standard library provides the usual memory and synchronization primitives on top of this unified memory system. The full details are documented in the library, but the basics should be familiar to anyone with a systems-level background. This is also where the @unsafe actually lives: a Mutex<T> mutates through a non-exclusive receiver via UnsafeCell and atomics, and its guard manufactures the &exclusive T that the runtime lock (not the borrow checker) guarantees - userland just composes the safe surface.

Primitive Contract
Box<T> unique heap ownership for Owned<T>, with deterministic drop when T: Drop
Rc<T> local shared ownership of Owned<T>, non-atomic refcount, not transferable across Workers
Arc<T> shared ownership of Owned<T>, atomic refcount, transferable when T satisfies the required Send / Sync bounds
Cell<T> local interior mutation by value, for small Copy-like state
RefCell<T> local runtime borrow checking for cases static borrowing cannot express cleanly
Atomic<T> lock-free scalar storage with explicit ordering and scope
AsyncMutex<T> local mutual exclusion that suspends the current async task, not the Worker
Mutex<T> shared mutual exclusion backed by atomics and runtime wait/wake support

Because ownership and placement are part of our type system, and we can query and gate based on contextual type information using regular TypeScript algebra, we have a lot of flexibility and gain some nice ergonomics. For example, we can provide ergonomic context-aware aliases that conform to the way they are used:

Synchronization.ds
1type Ref<T> =2    PlaceIn<T, "local"> extends "shared" ? Arc<T> : Rc<T>;3 4type Lock<T> =5    PlaceIn<T, "local"> extends "shared" ? Mutex<T> : AsyncMutex<T>;