Documentation / Memory and lifetimes

Memory and lifetimes

Moth shares existing values by default, requires explicit copies and infers safe cleanup transfers. Unlike Rust, it exposes no reference types, lifetime parameters or move syntax. Unlike a conventional garbage-collected language, its accepted design proves memory safety statically: alias and lifetime-topology validation are mandatory on every backend, and backends with full memory control produce release builds with no tracing collector. Lifetime-topology validation remains deferred in the compiler.

These pages explain source behaviour. The memory model defines borrow validation and backend lowering.

Choose the explanation level: Reference semantics

Reference semantics

Moth shares existing values by default. Here's what that means in practice.

When you assign one variable to another, both read the same data:

original = "hello"
view = original

No copy happens here. view reads the same value as original.

When shared access blocks mutation

A shared alias prevents mutation of the same data while it is still in use. Once the alias is no longer needed, mutation becomes valid again.

items ~= {1, 2, 3}
view = items
-- ~items.push(4)  -- would fail: view is still live
inspect(view)       -- last use of view
~items.push(4)      -- valid now

Moth defaults to reference semantics. Reading, binding, passing, returning or storing an existing value uses shared reference semantics unless exclusive access or explicit copy applies.

Shared access by default

Existing values use shared, read-only access. Multiple shared aliases may coexist. A shared alias doesn't imply ownership or implicit copy.

original = load_items()
view = original

inspect(original)
inspect(view)

view is a shared read-only alias of the same source value. It doesn't clone the data.

Non-lexical alias activity

Alias activity is non-lexical and control-flow-sensitive. A shared alias blocks overlapping mutation only until its last potential use on the relevant path. An unused shared alias doesn't block later mutation.

items ~= load_items()
view = items       -- shared alias
inspect(view)      -- last use of view
~items.push(42)    -- valid: view is no longer live

Branches, joins and loops

Branches are analysed independently. At a control-flow join, alias activity is conservatively retained when any incoming path may still use the alias. Loops use fixed-point future-use reasoning, so an alias that survives one iteration stays live for the whole loop body.

References are real

Moth omits explicit reference types and lifetime syntax, not references themselves. The language doesn't expose reference type constructors, pointer syntax or lifetime annotations. References are implicit, compiler-tracked and safe.

This is a source-semantic rule. Backends may use different representations (GC, handles, stack storage) as long as they preserve the same observable behaviour.

No source-level reference, move or lifetime syntax

Moth has no source-level:

  • & or &mut reference type constructors
  • lifetime annotations or lifetime parameters
  • temporary-reference syntax
  • explicit move keyword or operator
  • separate borrowed and owned function signatures

These are permanent design boundaries, not merely unimplemented features.

Backend representation doesn't change source semantics

A backend may represent a shared alias as a pointer, a handle, a GC reference or direct scalar storage. The source rule stays fixed: ordinary binding creates shared access to the same value, never an implicit copy.

Read next

For the formal memory model, see Memory management.

Choose the explanation level: Copy and exclusive access

Copy and exclusive access

Copies in Moth are always explicit. Ordinary assignment doesn't clone data.

Making an independent copy

Use copy when you need a separate version of a value:

original ~= {1, 2, 3}
independent ~= copy original

~independent.push(4)
-- original still has 3 items

Without copy, both names point at the same collection.

Mutating existing values with ~

To change an existing value, use ~ before the value name:

items ~= {1, 2, 3}
~items.push(4)

The ~ tells the compiler you want exclusive access to mutate the value.

Fresh values don't need ~

When you create a new value from scratch, it doesn't need ~:

fresh = {1, 2, 3}   -- new collection, no ~ needed
fresh_with_type ~{Int} = {1, 2, 3}  -- mutable binding, no ~ on the initializer

Explicit copy

copy creates an independent value graph. Ordinary assignment and argument passing don't clone existing values. Fresh aggregates built from shared inputs don't implicitly copy those inputs.

original ~= {1, 2, 3}
independent ~= copy original

~independent.push(4)
-- original is unchanged

copy performs a semantic deep copy of the complete copyable runtime value graph reachable from the place:

  • every copyable allocation in the graph is duplicated into the destination lifetime region
  • internal alias topology is preserved: repeated source references to one allocation become repeated references to one copied allocation
  • internal cycles are preserved in the copied graph
  • a copied acyclic graph may be allocated into an inferred or declared destination region
  • a copied cyclic graph must be allocated wholly into one declared region. Copying a cycle into an ordinary inferred region is invalid. Preserving internal alias topology does not override the rule that direct source-created cycles require one declared region
  • the copied graph shares no mutable allocation with the source graph
  • a reactive source is copied as its current value, not as the same reactive source identity
  • an external opaque handle or resource that doesn't define a genuine independent-copy operation produces a source diagnostic
  • a non-copyable graph member produces a source diagnostic, and the compiler won't silently retain an alias instead

copy accepts a visible binding, field projection or parenthesised place. Literals, templates, calls and computed expressions aren't copy places and are rejected.

Exclusive access with ~

~place requests temporary mutation-capable access to an existing mutable place. It excludes conflicting access while active.

values ~= {1, 2, 3}
~values.push(4)

~ isn't a move marker or a type constructor. It applies to an existing mutable place and requests exclusive access for one operation.

Mutable aliases versus fresh mutable slots

alias ~= source creates an exclusive write-through mutable alias to an existing place. Assignment through a mutable alias writes through to the referent.

A mutable declaration initialised from a fresh value creates an independent mutable slot.

original ~= 10
alias ~= original  -- mutable alias
alias = 20         -- writes through to original

fresh ~= 0         -- independent mutable slot
fresh = 1          -- doesn't affect original

Mutable receivers

Mutable receiver calls require explicit mutable or exclusive receiver syntax:

items ~= {1, 2, 3}
~items.push(4)     -- valid: explicit mutable access

-- items.push(4)   -- invalid: missing ~

Fresh rvalues can satisfy mutable parameters without ~. Existing places still require explicit ~. Temporaries and rvalues cannot be mutable receivers.

A fresh rvalue passed to an ordinary mutable parameter is materialised into a compiler-introduced hidden local before borrow analysis. That hidden local isn't source-visible lifetime syntax, and it doesn't make temporaries valid mutable receivers.

Call access summary

  • a shared parameter from an existing place needs no ~
  • a mutable parameter from an existing place requires ~place
  • a mutable parameter from a fresh value uses the plain value
  • a mutable receiver uses ~value.method(...)
  • an immutable receiver uses value.method(...)
  • mutable receivers require an existing mutable place

Read next

For the formal access rules, see Access and aliasing.

Choose the explanation level: Lifetimes and result shapes

Lifetimes and result shapes

Values in Moth have different lifetimes depending on how they are created.

New values

When you create a value from scratch, it is a fresh result:

name = "Priya"
greeting = [: Hello, [name]]
items = {1, 2, 3}

A fresh result has a new root allocation. It may still hold references to older values it read while building. Use copy when you need a value with no shared references to pre-existing data.

Aliases to existing values

When you read or pass an existing value, you get a shared alias:

original = load_items()
view = original

view and original point at the same data. No copy happens.

Values inside collections and structs

Values stored inside collections, maps and structs keep their shared reference semantics. Inserting a value into a collection doesn't copy it.

name = "Priya"
names ~= {name}     -- the collection holds a reference to the same string

Use copy when you need an independent duplicate inside a container.

Accepted mandatory topology, deferred validation

The accepted language contract requires mandatory, backend-independent lifetime-region and escape validation. That validation remains deferred in the current compiler. Once implemented, it will prove every retained reference legal under one lifetime owner, and GC won't bypass it.

After that proof succeeds, the compiler selects a physical strategy for each allocation: stack placement, cleanup at a proven final use, an inferred region, bulk release with a declared region, selective Retained Edge Counting or a garbage-collected representation. REC may handle a runtime-dependent number of persistent stored edges that disappear independently when a region would retain storage materially beyond its useful lifetime. Strategy selection changes memory quality, never program behaviour and never which programs are valid. When no narrower strategy applies, storage remains under its owning region until that region ends.

Backends that advertise full memory control lower release builds with no tracing collector. That is a property of the backend, not a mode you select in source or project config.

The rules below define the accepted end state rather than current compiler enforcement. Use the progress matrix for implementation status.

One semantic lifetime owner

Every runtime allocation has exactly one semantic lifetime owner. Multiple bindings, fields, elements or returned values may alias one allocation without becoming additional owners. Cleanup responsibility is a separate runtime fact that may move between paths but never duplicates. It doesn't prove uniqueness, and the semantic lifetime owner stays fixed regardless of where cleanup happens.

The retained-edge outlives rule

An object may retain a reference only when the referenced allocation belongs to the same lifetime region or to a region statically known to outlive the retaining object.

R_value >= R_container

where R_value lives at least as long as R_container.

Valid edges include a local retaining a longer-lived parameter, a value owned by a child declared region retaining a value owned by a parent declared region, or two fields of one page-owned aggregate sharing one page-owned child.

Invalid edges include a longer-lived object retaining shorter-lived storage, a parent retaining a value owned by a child declared region, or a returned local outliving its source region without independent storage.

Lexical scope doesn't define allocation lifetime

Shared aliases may outlive the lexical binding that first named the storage. Escaping or retained aliases remain under one lifetime owner and must satisfy the stored-edge outlives rule. Lexical scope controls name visibility and control-flow exits, not allocation lifetime.

Nearest-existing-ancestor widening

An ordinary allocation begins in the narrowest inferred region capable of owning its initial uses. The compiler may widen that allocation only to the nearest existing ancestor on the same ordered owner chain that outlives every retained observer.

Widening follows one ordered owner chain only. The compiler must not widen farther than necessary or invent a page-, application- or process-lifetime owner merely to avoid a diagnostic.

Siblings don't form one ordered chain. Independently ending lifetime domains cannot be laterally promoted across each other. Sharing across sibling domains requires one of:

  1. an already-existing common semantic owner
  2. an enclosing declared region
  3. a builder-declared common lifecycle
  4. independent storage created by copy

Fresh result roots

A fresh result root has a new root allocation but may retain legal references to older allocations. Fresh results include literals, templates, constructor calls and computed aggregates.

name = "Priya"                    -- fresh string slice
greeting = [: Hello, [name]]      -- fresh owned template
items = {1, 2, 3}                 -- fresh collection

A fresh result root may retain parameters or other pre-existing values only when every retained edge satisfies the destination lifetime's outlives constraints.

Alias results

An alias result reuses an existing root or projection. Shared bindings, field access and shared function returns produce alias results. An alias result stays tied to its existing lifetime owner and cannot become new declared-region-owned storage through into.

original = load_items()
view = original                   -- alias result
first = original.get(0) catch:    -- alias result (shared access)
    assert(false, "known valid index")
;

Projection roots

Interior projections remain rooted in their containing allocation family. Returning, storing or escaping a projection retains that family. A projection doesn't silently become an independent allocation or independent copy.

Interior projection detachment means moving a projected field out of its containing allocation family. It remains deferred until field-sensitive splitting has established separate ownership. This restriction does not prohibit a container-detached result from remove, which kills a collection-retained edge and returns the already-stored value under ordinary lifetime rules.

Result binding versus allocation identity

Every returned value enters a fresh caller binding slot. Binding-slot freshness and allocation freshness are separate facts: a fresh slot may contain a value that aliases an older allocation. Rebinding that slot replaces its current value provenance; it does not rebind or mutate another binding that still observes the older allocation.

original ~= Point(1)
returned ~= identity(original) -- fresh caller slot, aliased allocation

returned = Point(2)             -- clears returned's old allocation provenance
returned.x = 3
original.x = 4                  -- the original allocation remains independent

A call result owns a separate binding even when its value aliases an argument. A fallible return also has a separate carrier while its success payload may alias an argument allocation. Rebinding either result detaches that binding from its old allocation without rebinding another alias.

Independent result graphs

An independent result graph has no retained Moth reference to pre-existing storage. copy produces independent results. WIT value-only lifting produces independent results. An independent acyclic graph may enter an unrelated destination lifetime without retained-edge constraints to its source graph. A copied cyclic graph preserves its internal alias topology, so the whole graph must enter one declared region. It cannot enter an ordinary inferred region.

Aggregate storage

Existing values stored in structs, choices, collections, maps, tuples, templates or other aggregates retain shared reference semantics by default. copy creates independent storage.

Maps own their entry structure while keys and values follow the same shared/copy/inferred-transfer rules. Map lookup keys are borrowed. get returns a shared alias. remove returns the removed value under normal lifetime and ownership rules.

Return and multi-return aliasing

Function lifetime summaries cover fresh result roots, parameter aliases, projection aliases, detached stored results, result-to-result aliases, independent result graphs, retained-parameter constraints, persistent-retention effects, retention cardinality, whole-domain kills, outcome-sensitive success and error effects and required outlives relationships.

Multiple return values may alias one allocation when they remain under one caller lifetime owner. A caller may place a fresh result root directly into a destination region only when every retained edge in the result is legal for that destination.

Current multi-return analysis may conservatively treat each projected result as aliasing any parameter root returned by the function. It may reject a mutation that more precise per-result analysis could prove independent.

Cycles

Cross-region cycles are invalid. Every strongly connected allocation graph must belong to one lifetime region. A declared region is the only source mechanism for building a direct cycle: the compiler never invents a cyclic region on your behalf. copy preserves internal alias topology, but copying a cycle into an ordinary inferred region remains invalid. Direct source construction of cyclic graphs is deferred with the rest of declared-region implementation.

Reactive and builder-owned lifetimes

Reactive subscriptions are read-only dependencies, not active borrow lifetimes. Builder-supplied page, mount, request, frame and arena roots are lifecycle inputs. They obey the same retained-edge rule as source regions. Builders supply lifecycle roots but cannot change source legality.

External boundaries

External bindings use closed semantic boundary profiles. They don't expose arbitrary user-defined lifetime graphs.

WIT value-only V1 imports lower shared reads into independent component values and lift results into independent Moth result graphs. No Moth alias, lifetime owner or destruction responsibility crosses the component boundary.

Restricted host-binding profiles let ordinary Moth values cross by value, while host code may not retain references into ordinary Moth storage and opaque handles represent foreign identities rather than Moth reference types.

Optional inferred transfer

Moth has no move syntax and no ordinary mandatory-consuming value operation. Inferred transfer is optional. When safe transfer isn't proven on every relevant path, the operation remains a borrow.

Immutable and mutable parameters may both receive inferred destruction responsibility at a proven final-use call site. Parameter access mode remains separate from optional ownership effect.

Source-visible lifetime consequences

  • A shared alias blocks overlapping mutation until its last potential use.
  • A live shared value returned by get prevents mutation of the same map.
  • remove returns the removed value under normal lifetime rules.
  • Aggregate storage retains shared references unless copy is explicit.

Read next

For the formal lifetime-region rules, see Lifetime regions and escape validation.

Choose the explanation level: Automatic cleanup and retained edges

Automatic cleanup and retained edges

Most Moth aliases end through last-use analysis. A stored reference inside a collection or map can live longer than the operation that inserted it.

clear() can remove a collection's complete retention domain. If nothing else keeps a value alive, that point can end the value's region even while the collection remains usable.

When stored aliases disappear one at a time and a region would keep too much storage alive, the compiler may track those stored obligations internally with Retained Edge Counting. You never write REC code or inspect its counter.

A declared region chooses one bulk cleanup. Declared-region-owned values never use REC.

Accepted automatic cleanup, deferred implementation

Moth proves access safety and lifetime topology before it chooses physical cleanup. Most values need no runtime count. Last-use analysis handles ordinary aliases, inferred regions handle bounded stored lifetimes and declared regions choose one deliberate bulk lifetime.

When a runtime-dependent number of stored aliases disappears one at a time, the compiler may select internal Retained Edge Counting (REC). REC remains a compiler choice. It never changes which source programs pass borrow and lifetime validation.

Temporary aliases versus stored references

A local alias or a get() result borrows an existing value for a bounded period. Last-use analysis tracks that access, and REC never counts it.

A value stored in a collection, map, struct field or other aggregate can outlive the operation that inserted it. That stored reference forms a retained edge. The stored value contributes a retained-edge summary. A scalar can contribute zero obligations, a direct heap-backed value can contribute one, and an inline aggregate that physically stores several references can contribute several.

Nested values can describe several stored references, but the compiler does not count every transitively reachable allocation. It resolves the final stored references against the actual allocation families. A separately allocated child owns its own outgoing references.

Take a collection of Holder values, where each Holder refers to a Blob:

collection -> Holder -> Blob

If Holder is its own allocation, the collection holds one stored reference to Holder, and Holder holds its own stored reference to Blob. Storing a Holder does not also create a second obligation from the collection to the Blob. When Holder goes away, it releases its own reference to Blob.

If the compiler instead packs Holder directly into the collection's storage, the Blob reference now sits in the collection's own storage, so it does count as a stored reference from the collection.

Storage operations add or remove the complete set of stored references contributed by the stored value. They don't assume that one element means one reference, and they never count the same allocation twice just because it is reachable through a chain.

Last use and stored obligations work together

A separately releasable heap value starts with one cleanup obligation: something has to release it. Storing it can hand that obligation over rather than creating a new one. Scalars, region-owned values and values the compiler never releases individually have no such obligation to begin with.

  • A separately releasable heap value starts with one cleanup obligation.
  • If storing it is that value's final use, the compiler can turn that existing obligation into the stored one. Nothing extra needs tracking.
  • If one operation stores two direct references to the same allocation, only one of them can reuse the original obligation.
  • Any additional direct reference is a genuinely new obligation.

So storing a value twice in one operation, such as using it as both a map key and a map value, does add work even at a final use. Storing it once at a final use does not.

The reverse can happen too. Detachment, such as remove taking a stored value back out into an owned result, can hand the stored cleanup obligation to that result instead of creating new work.

Replacing one stored reference with another reference to the same value in one operation is a same-family, atomic change with no net extra tracking. The compiler does not briefly drop the value to nothing and then put it back.

This is why last-use analysis still matters when a count is involved. The count is not tracking your aliases. It tracks the stored references that are still live in a counted family, plus at most one outstanding owned reference, and last-use analysis is what keeps that number as small as it can be.

When a region is enough

Use a region when a whole retention domain can end at one known frontier:

blob = load_blob()!

loop names |name|:
    ~index.set(name, blob)!
;

use(index)
~index.clear()

clear() kills the collection's complete retention domain. If no other live alias or retention domain can keep blob alive, the call becomes its final cleanup frontier. The collection and its backing storage may continue to live. Another map, field, local alias or builder lifecycle can keep the same allocation family alive, so clear() does not promise immediate reclamation on its own.

When REC may be selected

Use a count only when stored edges disappear independently and a single region would keep too much storage alive:

loop keys_to_remove |key|:
    ~index.remove(key) catch:
    ;
;

Runtime execution decides which entry disappears and which removal leaves the last persistent edge. The compiler may use REC to track those persistent obligations. A failed removal leaves the original entry and its obligation in place.

What REC doesn't count

REC never counts:

  • local aliases
  • parameters
  • temporary projections
  • get() results
  • handing cleanup responsibility to another place moves or reclassifies the one obligation that already exists, so it never adds another.
  • declared-region-owned values

The compiler selects REC only after static cleanup, inferred regions, cleanup frontiers, field-sensitive splitting, bounded retention and declared regions have failed to give a precise enough answer. Every REC family keeps its statically proven fallback region and can reclaim the family earlier than that region frontier.

Declared regions

A declared region chooses one hard lifetime and one bulk cleanup domain. Last-use analysis still checks access inside it, but declared-region-owned allocations remain until region exit. Declared-region-owned storage carries no REC counter. A declared region is also Moth's only source mechanism for direct reference cycles.

Fallible operations commit effects on success

Builtin collection and map mutations publish retained-edge effects atomically on the successful path. A failed fixed-capacity push, or a failed set, remove or map insertion, preserves the original storage topology and cleanup obligations. Public summaries describe the two exits separately:

```text success: apply the retained-edge effect

error: no retention change ```

A fallible operation only takes over responsibility for cleaning up a value when that hand-over is safe on every outcome, success and failure alike. If the failure path still uses the value, the operation just borrows it instead. There is no special protocol for giving a value back after a failure: a failed operation stored nothing, so there is nothing to give back, and the caller's original arrangement is untouched.

Source visibility

You never declare, inspect or configure an REC counter.

Only retention-sensitive commits on values the compiler chose to count can change a count: storing into or removing from a counted family, and cleanup points. Reading, passing, borrowing and ordinary calls never change a count. A call that stores or removes changes it only at that operation's planned commit point.

REC is one possible compiler-selected physical strategy for source code that already passed the same borrow and lifetime checks as every other program. Debug or garbage-collected representations may erase cleanup operations, while a capable full-control release backend cannot fall back to tracing garbage collection.

For the compiler contract, see Retained Edge Counting.

Choose the explanation level: Declared regions

Declared regions

Moth has an accepted but not yet implemented feature called declared regions. This page explains what they are so you know the concept exists.

What declared regions do

request: declares a region and opens its body. A declaration becomes owned by that region only when it uses into request.

request:
    parsed ParsedPost into request = parse_post(post)
    html String into request = render_post(parsed)
;

The declared region name is written directly before :. There is no group keyword. An ordinary declaration inside the body keeps normal inferred lifetime behaviour.

Every declared-region-owned value stays with the declared region until it exits. Last-use analysis still checks access, but it never releases one declared-region-owned allocation early. The declared region reclaims its contents together in one bulk cleanup. Declared-region-owned storage carries no REC counter because the declared region never releases one child on its own.

A declared region also provides Moth's only source mechanism for direct reference cycles. Every member of a cycle must belong to the same declared region. Inferred regions never become cyclic.

Scope surface

Moth has no dedicated general lexical-block construct. block is an ordinary identifier, so block: declares a region just like any other valid name: header. Exact _: is invalid. Bare names followed by : are reserved for declared regions, not control-flow labels. Labelled break and continue are not part of the language.

An ordinary statically known if true: still creates a lexical branch scope and is removed before HIR. It does not create a declared region and does not change declared region placement rules.

Current status

Declared region syntax is accepted but deferred. Current programs cannot declare or place values into declared regions yet.

The compiler reserves valid name: headers with a deferred-feature diagnostic and rejects exact _:. Full declared region parsing and into placement remain deferred.

You do not need declared regions to write Moth programs today. Current shared-access, explicit-copy and ~ rules cover access conflicts, while lifetime-topology validation remains deferred.

Accepted deferred syntax

The following is accepted end-state syntax. Implementation is deferred and must not be treated as current source support.

Declared regions

name: creates a declared hard lifetime region in runtime statement position. Values placed into a declared region belong to that declared region for their full lifetime. No declared-region-owned value, projection or alias may outlive the declared region.

A declaration targets the declared region with the into keyword:

request:
    parsed ParsedPost into request = parse_post(post)
    html String into request = render_post(parsed)
;
name [access/type] into region_name = expression

There is no group keyword. The declared region name is the ordinary value-like identifier before :.

Bulk cleanup and cycles

A declared region forms one deliberate allocation and cleanup domain. Every declared-region-owned allocation remains under that declared region until declared-region exit. Last-use analysis still checks access inside the declared region, but it never shortens declared-region-owned storage or releases one child early. The declared region reclaims its contents together in one bulk cleanup. Because declared-region-owned storage is reclaimed in bulk, it carries no REC counter.

Count-free is a property of what the declared region owns, not of every reference written inside it. A reference from declared-region-owned storage to a value the same declared region owns is free. An external REC target is different. A reference from declared-region-owned storage to a value owned outside the region that the compiler chose to count is still a counted reference to that outside value. At declared-region exit, the compiler releases those outgoing references to outside values first, then reclaims the declared region's own storage in bulk.

The rule that direct source-created cycles require one declared region follows from that bulk lifetime. A declared region provides the only source mechanism for constructing a direct reference cycle. Every member of the cycle must belong to the same declared region. Inferred regions never become cyclic, and a copied cyclic graph must enter one declared region as a whole.

Declared region header and placement rules

  • name: is valid only in runtime executable bodies
  • The declared region name follows normal value-like identifier policy
  • Exact _: is invalid and does not create an anonymous declared region
  • into region_name appears only on declaration receiving boundaries in V1
  • Placement may target the current declared region or a lexically enclosing ancestor declared region
  • Placement may not target a sibling, child, unrelated declared region or builder lifecycle root by spelling its name
  • Declared regions cannot be passed, returned, stored, imported, exported, compared or used as values
  • Declared-region identity is not a type, field, parameter, generic argument, trait, allocator object or lifetime annotation
  • Declared-region identity must not enter TypeId or a source signature
  • Declared region closure occurs on every exit: fallthrough, return, return!, break, recovery exit and checked-operation failure path

The declared region name is local to the current executable body scope. It cannot collide with a visible value, type, dependency binding, constant, reactive source or active declared region.

Ordinary declarations written inside the declared region are not implicitly declared-region-owned. Only a declaration that uses into region_name places its result into that declared region.

A declared region with no direct or nested placement targeting it produces an unused-region warning. This discourages treating declared regions as labels or general lexical blocks.

No general lexical-block feature or labels

Moth has no dedicated general lexical-block construct. block is an ordinary identifier, so block: has the same declared-region meaning as any other valid name: header. Exact _: is invalid. Bare identifier: syntax is reserved for declared regions, not arbitrary control-flow labels.

Moth does not add labelled break, labelled continue or goto. Unlabelled break and continue keep targeting the nearest enclosing loop.

An ordinary statically known if true: can provide a rare local lexical scope:

if true:
    temporary = calculate()
    use(temporary)
;

This is not a special block alias. It follows ordinary static Bool if rules, preserves the selected branch scope and disappears before HIR. It does not create a declared region or change ancestor-placement legality.

Keyword-led semantic scopes such as async: remain distinct. Their keyword describes language-defined execution behaviour rather than naming a user-declared lifetime region.

into declaration position

into region_name appears after access or type syntax and before =. V1 has no expression-site placement. Declarations are the only placement surface.

parsed into scratch = parse_post(post)
parsed ParsedPost into scratch = parse_post(post)
rows ~{Row} into scratch = {}
maybe_name String? into scratch = find_name(id)?

Prefer:

row Row into scratch = parse_row(raw)
~rows.push(row)

Do not initially add:

-- INVALID: expression-site placement is not supported in V1
~rows.push(parse_row(raw) into scratch)

Placement stays attached to closed receiving boundaries.

Destination scope and visibility

A declaration targeting an ancestor declared region is a narrow V1 escape mechanism, not a general definite-assignment system.

  • A declaration targeting an ancestor declared region is legal only from a straight-line nested declared region that executes at most once
  • It is invalid directly inside if branches, match arms, catch branches, loops, repeated template or runtime control flow or any construct that may execute zero or multiple times
  • A statically known if true: is still conditional syntax for this rule and does not make ancestor placement legal
  • Conditional production must use one declaration in the destination scope whose initializer is a value-producing if, match or catch
  • Loop production must mutate a collection or aggregate already owned by the destination declared region through ordinary exclusive access
  • Name collisions and definite initialization are checked in the destination scope
  • Visibility begins at the declaration point and continues through the remainder of the destination declared region

Ordinary declarations without into retain normal lexical visibility.

Placement eligibility

A declaration may be placed into a declared region when its result root is fresh and every retained edge is legal for the destination declared region, or when it is an independent result graph such as an explicit copy.

An alias result cannot become new declared-region-owned storage through into.

A fresh result root may retain parameters or other pre-existing values only when every retained edge satisfies the destination lifetime's outlives constraints. An independent acyclic result graph can enter an unrelated destination lifetime without retained-edge constraints to its source graph. A copied cyclic graph must enter one declared region as a whole.

Nested declared regions and retained edges

Nested declared regions are valid.

request:
    config Config into request = load_config()

    scratch:
        parsed ParsedPost into scratch = parse_post(post)
        html String into request = render_post(parsed, config)
    ;

    use(html)
;

For a child declared region nested in a parent declared region:

  • a child value may retain a parent value: valid
  • a child value may retain a same-child value: valid
  • a parent value may retain a child value: invalid
  • a sibling may retain a value owned by a sibling declared region: invalid
  • a child may retain an unrelated shorter-lived value: invalid

A declared region has one lexical entry and explicit exits. Values placed into the declared region cannot escape it. The compiler must not silently widen a declared region. A child declared region ends before its parent.

No declared-region transfer in V1

V1 has no declared-region extraction, declared-region adoption or unrestricted declared-region transfer. A value crosses from a shorter-lived declared region into a longer-lived declared region only by producing independent storage in the destination lifetime or by invoking a fresh producer that allocates directly into the destination.

Use one of:

  • allocate the result directly into the destination declared region
  • copy into the destination declared region
  • place the whole graph in the correct common declared region from the start

The restriction on extraction means moving a declared-region-owned allocation out of its declared region or retroactively detaching an interior projection from its allocation family. It does not prohibit builtin collection or map remove, which kills a container-retained edge and returns the already-stored value under ordinary lifetime rules.

Escapes

Lifetime-region validation rejects every path where a declared-region-owned value or alias can outlive the declared region.

Invalid escapes include:

  • returning a declared-region-owned value
  • returning a projection or alias rooted in declared-region-owned storage
  • storing a declared-region-owned value in a longer-lived local or aggregate
  • storing a value owned by a child declared region in a parent or sibling declared region
  • assigning a declared-region-owned value into longer-lived reactive storage
  • passing a declared-region-owned value to an external call that may retain it
  • keeping a map lookup, collection element or field alias live after declared-region exit
  • creating a longer-lived retained edge into the declared region

Backend GC representation does not legalise these cases.

Reassignment inside a declared region

V1 has no into placement on reassignment. A mutable binding already owned by a declared region may be reassigned only with:

  • a fresh result root valid for that same declared region
  • an independent copy allocated into that declared region
  • a value already owned by the same declared region reused or rebound at a proven final use

Reassignment does not let a declared-region-owned binding switch into a borrowed alias of ancestor, sibling or external storage. Reusing or rebinding a value already owned by the same declared region at final use does not transfer individual cleanup responsibility. The declared region remains responsible for cleanup. Use a separate ordinary alias binding for an ancestor or external value.

Reactive storage restrictions

V1 rejects:

  • reactive declarations placed directly into a declared region
  • assigning declared-region-owned values into reactive storage that outlives the declared region
  • subscriptions or mounted state retaining declared-region-owned aliases past declared-region exit

Calls and hidden result destinations

Functions whose result root is fresh may allocate that root directly into a caller-selected destination. The function summary classifies the result root as fresh and records retained-edge constraints separately.

A hidden destination:

  • is not part of the source signature
  • is not a region parameter visible to generics or callers
  • is not a source lifetime parameter
  • may be ignored by a GC backend
  • lets an optimising backend allocate a fresh result root directly into the caller's region when every retained edge is legal

Hidden result destinations do not become source signature parameters.

V1 restrictions

  • no expression-site placement
  • no into placement on reassignment
  • no declared-region extraction or declared-region adoption
  • no unrestricted declared-region-to-declared-region adoption
  • no anonymous declared region
  • no general lexical block
  • no control-flow labels

Why declared regions exist

Declared regions give authors explicit control over one allocation and cleanup domain without exposing reference types, pointer syntax or manual memory management. Their defining performance property comes from predictable bulk cleanup: the compiler can reclaim the declared region together at exit instead of releasing children one by one. The compiler validates declared region topology as part of its mandatory lifetime-region analysis.

Current implementation status

Declared-region semantics and into placement are not implemented yet. The compiler reserves valid name: headers with a targeted deferred-feature diagnostic, rejects exact _: and treats block, group and region as ordinary identifiers. There is no authored general lexical-block parser.

Read next

For the formal declared region semantics, see Declared regions. For implementation sequencing, see Roadmap.