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). let user: User = new User(); // managed: the runtime owns it let owned: ^User = new User(); // owned: this binding owns it, dropped after last use let borrow: &User = &user; // mutable borrow of the managed value let view: &readonly User = &readonly user; // OK: readonly may overlap the mutable borrow let claim: &exclusive User = &exclusive user; // ERROR: `borrow` and `view` are still live let 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. struct Point { x: int32; y: int32; } class User { name: string = ""; } let user: User = new User(); // `&user.name` borrows through the managed `User` handle let name = &user.name; // `name` is borrowed access into managed storage name satisfies &string; let point = ^Point { x: 1, y: 2 }; // `&readonly point.x` borrows from owned storage without moving `point` let readX = &readonly point.x; // `&point.x` *can* overlap with readonly borrowed access let writeX = &point.x; *writeX = 3; // `&point.y` mutably borrows a disjoint field let writeY = &point.y; *writeY = 3; // the readonly borrow is still valid here readX satisfies &readonly int32; // `&exclusive point.x` is allowed after the overlapping borrows are no longer live let exclusiveX = &exclusive point.x; *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. struct Frame { pixels: ^Buffer; // owned interior storage } let frame: ^Frame = Frame { pixels: Buffer.open() }; let pixels = &frame.pixels; // interior borrow into the owned buffer let alias = &frame; // non-exclusive borrows may overlap *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. class Profile { name: string; } class User { profile: Profile; } let name = &user.profile.name; // roots the `user.profile` handle user.profile = new Profile("other"); // OK: the old profile stays pinned print(*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): let value: Buffer; if (enabled) { value = Buffer.open(); } process(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) let value: Buffer | undefined; if (enabled) { value = Buffer.open(); } if (value != undefined) { process(value); } Lifetimes Lifetimes tie a borrow to its source, and in Destack they are just generics: parameters on Borrowed, 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. function read(user: &User): &string { return &user.name; } function first(items: &[T]): &T { return &items[0]; } Elided borrowed forms get hidden generic lifetime parameters in all declarations, not just in function signatures. // elided form struct WorldView { engine: &Engine; assets: &AssetStore; } // explicit form struct WorldView { engine: Borrowed; assets: Borrowed; } 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: // elided form function first(a: &Node, b: &Node): &Node { return a; } // explicit form function first( a: Borrowed, b: Borrowed, ): Borrowed { return a; } // elided form function choose(a: &Node, b: &Node, flag: boolean): &Node { return flag ? a : b; } // explicit form function choose( a: Borrowed, b: Borrowed, flag: boolean, ): Borrowed { return flag ? a : b; } Because declare functions do not have bodies, it follows that declaration-only APIs must spell out lifetime relationships explicitly. // rejected declare function only(value: &Node): &Node; // accepted declare function only(value: Borrowed): Borrowed; // rejected declare function choose( a: &Node, b: &Node, ): &Node; // accepted declare function choose( a: Borrowed, b: Borrowed, ): Borrowed; 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: // owned by the frame: the borrow may cross suspension async function readOwned(user: ^User): Promise { const name = &readonly user.name; await tick(); return name.clone(); } // borrowed parameter: also fine, the caller proves the source is owned or static async function readBorrowed(user: &User): Promise { const name = &readonly user.name; await tick(); return name.clone(); } // managed: rejected, another continuation could touch `user` while we are parked async function readManaged(user: User): Promise { const name = &readonly user.name; await tick(); // ERROR: `name` borrows managed storage across suspension return name.clone(); } 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. function run(): void { let buffer: ^Buffer = Buffer.open(); process(&buffer); // `buffer` is dropped immediately after its last use } The same Drop also works for managed references, where it is run before they are freed: class Image { pixels: Unique<[uint8]>; } let image: Image = new Image(); // 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: using file = File.open(path)?; await 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 stores a value outside automatic drop handling, and Box.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. let a: User = new User(); // managed let 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, Unique, 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: try { let buffer: Block[] = Array.tryWithCapacity(count)?; // fallible library allocation let page = new? Page(buffer); // fallible managed allocation page.fill()?; return page; } catch (e: AllocationError) { return null; } Code can also opt out of managed allocation locally via restrictions: @noManaged function processFrame(input: &[Sample]): ^Frame { return buildFrame(input); } @noHeap function interruptHandler(input: &[Sample]): Frame { return buildFrameOnStack(input); } 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. struct Request { header: Header; body: T; } let localRequest: Request; // ambient, default -> Request is local heap let sharedRequest: shared Request; // 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: local newtype interface Awaitable { await(): T } local class Promise {} shared class Channel {} local newtype TaskId = uint64; PlaceOf> satisfies "local"; PlaceOf> satisfies "shared"; PlaceOf 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: shared const world: Mutex = new Mutex(new World()); const 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: let user = new User(); let borrow: &User = &user; // default: a checked borrow let pointer: *User = &user; // typed as raw: an inert, unchecked pointer value let 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: let user = new User(); let borrow: &User = &user; let 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(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: /// Automatically managed T, owned by the runtime. newtype Managed = intrinsic; /// Owned T (`^T`). newtype Owned = intrinsic; /// Borrowed T (`&T`). newtype Borrowed = intrinsic; /// Raw T (`*T`). newtype Raw = intrinsic; /// Placed T (`local T` or `shared T`). newtype Placed = intrinsic; Specifically, all surface sigils and keywords are just compact syntax for those intrinsic forms that commute the way they read: declare class User { /* ... */ }; declare struct Point { /* ... */ }; type NormalUser = User; // unqualified, normal default representation type NormalPoint = Point; // unqualified value type, default-owned/direct type ReadonlyUser = readonly User; // WithAccess type OwnedUser = ^User; // Owned type OwnedPoint = ^Point; // Owned, reduced to Point type ReadonlyBorrowedUser = &readonly User; // Borrowed type BorrowedUser = &User; // Borrowed type ExclusiveBorrowedUser = &exclusive User; // Borrowed type RawUser = *User; // Raw type LocalUser = local User; // Placed type SharedUser = shared User; // Placed type LocalOwnedUser = local ^User; // Placed, "local"> type SharedOwnedUser = shared ^User; // Placed, "shared"> type OwnedSharedUser = ^shared User; // Owned> It follows that because forms compose, owning a borrow is different from borrowing an owner: Owned> // owns a borrow value Borrowed, L> // borrows an owned value Destack also provides builtin accessors to inspect composed forms, e.g., PayloadOf removes one outer form, while BaseOf removes all transparent memory forms: BaseOf satisfies User; PayloadOf>> satisfies Borrowed; OwnershipOf<^User> satisfies "owned"; OwnershipOf satisfies "owned"; OwnershipOf satisfies "managed"; OwnershipOf>> satisfies "owned"; OwnershipOf, L>> satisfies "borrowed"; OwnershipOr satisfies "managed"; AccessOf satisfies "mutable"; AccessOf satisfies "readonly"; AccessOf<^readonly User> satisfies "readonly"; AccessOf<&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: PlaceOf satisfies "ambient"; SpaceOf<^User> satisfies never; PlaceIn<^User, "shared"> satisfies "shared"; PlaceOf satisfies "local"; SpaceOf satisfies "local"; PlaceIn satisfies "local"; PlaceOf satisfies "shared"; SpaceOf satisfies "shared"; PlaceIn satisfies "shared"; PlaceOf satisfies "ambient" | "local" | "shared"; SpaceOf satisfies "local" | "shared"; PlaceIn satisfies "local" | "shared"; PlaceIn satisfies "local" | "shared"; Predicates with Is* are convenience wrappers around those same accessors: IsOwned<^User> satisfies true; IsBorrowed<&User> satisfies true; IsShared satisfies true; IsShared<^User> satisfies false; IsSharedIn<^User, "shared"> satisfies true; Rewriting helpers preserve the rest of the type instead of rebuilding from a stripped base type: WithSpace<^User, "shared"> satisfies Placed<^User, "shared">; WithOwnership satisfies Owned; WithPlace satisfies Placed; WithAccess satisfies readonly User; WithAccess<^User, "readonly"> satisfies ^readonly User; WithAccess<&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. struct Buffer { lock: PlaceOf == "shared" ? Mutex : (); value: T; } declare const localBuffer: Buffer; declare const sharedBuffer: shared Buffer; sharedBuffer.lock satisfies Mutex; localBuffer.lock satisfies (); PlaceOf satisfies "ambient"; PlaceOf 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: process(user); // managed/default value process(&readonly user); // readonly borrowed access process(&user); // mutable borrowed access process(&exclusive user); // exclusive borrowed access process(^user); // owned value Dispatch resolution uses the actual form during overload resolution, so container interfaces like Iterable can support ordinary TypeScript iteration and borrowed iteration without adding Rust-style method family explosion: declare type Point = { x: number; y: number }; declare const points: Array; // ordinary "managed" iteration for (const point of points) { point satisfies Point; } // readonly borrowed iteration for (const point of &readonly points) { point satisfies &readonly Point; } // mutable borrowed iteration for (const point of &points) { point satisfies &Point; } // exclusive borrowed iteration for (const point of &exclusive points) { point satisfies &exclusive Point; } // moved iteration for (const point of ^points) { point satisfies Point; } 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 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 | unique heap ownership for Owned, with deterministic drop when T: Drop | | Rc | local shared ownership of Owned, non-atomic refcount, not transferable across Workers | | Arc | shared ownership of Owned, atomic refcount, transferable when T satisfies the required Send / Sync bounds | | Cell | local interior mutation by value, for small Copy-like state | | RefCell | local runtime borrow checking for cases static borrowing cannot express cleanly | | Atomic | lock-free scalar storage with explicit ordering and scope | | AsyncMutex | local mutual exclusion that suspends the current async task, not the Worker | | Mutex | 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: type Ref = PlaceIn extends "shared" ? Arc : Rc; type Lock = PlaceIn extends "shared" ? Mutex : AsyncMutex;