On this page

Receiver scopes, errors and resources

Receiver scopes organize related operations. Resource scopes establish deterministic cleanup. Error-handling scopes protect execution. Their similar block structure does not give them the same semantics.

Receiver scopes

use evaluates and captures its receiver once. It is strict: a missing receiver or chain segment fails according to required-access rules instead of silently skipping the scope. Leading . addresses the captured receiver; nested scopes bind it to the innermost active receiver. Body calls, assignments and getter failures propagate normally.

require invoice is not nothing

let total = use invoice
    .Recalculate()
    take .Total
end use

This is an action fragment with an Invoice exposing Recalculate and Total. It updates the same object and returns a projection. Without a final take, expression-form use produces the captured receiver. It does not construct, copy or dispose that object.

The explicit require supplies an application-specific failure message; it is not needed to make use strict. use receiver as T requires a present receiver compatible with T; absence fails and incompatibility follows invalid-cast behavior.

use_if makes optional qualification explicit:

use_if customer.Account.Owner
    .Notify()
end use_if

The qualification chain runs left-to-right once. An absent segment skips later qualification and the body. An evaluated getter's exception is not absence and still propagates. Once qualified, body member access is ordinary access.

use_if receiver as T additionally tests runtime type compatibility; failure skips the scope. Added and predicates execute only after the receiver/type qualifies. A top-level or cannot bypass receiver qualification. Without take, the expression result is the receiver when executed and nothing otherwise.

A supporting receiver scope allows at most one final take. Branch-local or multiple take exits are invalid. Compute a local and project it at the end when branching is needed. return still exits the enclosing action.

Deterministic resource lifetime

Bounded using acquires explicitly declared resources left-to-right and cleans successfully acquired resources up in reverse order. Later initializers may reference earlier resources.

action ReadReport(path as String)
    returns String
    let text = using reader = new Global.System.IO.StreamReader(path)
        take reader.ReadToEnd()
    end using

    return text
end action

Reference C#:

public string ReadReport(string path)
{
    string text;
    using (var reader = new global::System.IO.StreamReader(path))
    {
        text = reader.ReadToEnd();
    }
    return text;
}

A result-producing using block captures take before cleanup, then produces that captured result only if cleanup succeeds. A value-required using block without a final take is an error. It must not implicitly return the disposed resource.

let using resource = expression extends ownership to the end of the containing lexical scope. An ordinary let does not acquire disposal responsibility just because its type is disposable.

Cleanup occurs on normal completion, return and exception. Failed later acquisition still cleans earlier resources. Disposal exceptions follow normal .NET behavior. The scope owns only its declared resources and does not infer ownership of dependencies or unrelated members.

Choose cleanup from the static contract

Both bounded using and lexical let using select a protocol from the resource's resolved static contract. An asynchronous context prefers IAsyncDisposable when that contract exposes it and otherwise uses IDisposable. This includes explicit and inferred async actions, async dispose, async iterators and asynchronous owned workers. The compiler awaits selected cleanup without a separate await-using spelling. A fixed synchronous context requires IDisposable; an async-only resource is a compile-time error there, without an automatic blocking bridge.

Static resource contract Synchronous context Asynchronous context
IDisposable only Dispose Dispose
IAsyncDisposable only Compile-time error Await DisposeAsync
Both interfaces Dispose Await DisposeAsync
Neither interface Compile-time error Compile-time error

A local declared as IDisposable keeps synchronous disposal even when the underlying object implements both interfaces. An IAsyncDisposable view remains async-only even if the underlying object also supports synchronous disposal. The compiler does not discover additional runtime interfaces to change its selection.

Async-only owned cleanup can trigger ordinary action inference, including public inference, even when there are no literal awaits. A resource exposing both protocols does not by itself force a synchronous action to become async: determine the body's required execution context first, then choose its cleanup route. Thus a synchronous body with a dual-interface resource uses Dispose; the same body with another required await uses DisposeAsync. Nested using inside async dispose also prefers asynchronous cleanup. None of this converts plain dispose into async dispose or changes a fixed interface/base contract.

Mixed groups retain left-to-right acquisition and reverse cleanup, choosing each route independently. An asynchronous cleanup finishes before cleanup proceeds to the preceding resource. This selection rule applies equally to imported .NET resources and authored DirectSharp resources. It chooses which protocol to invoke; the selected object's disposal contract determines its behavior. See CS-RESOURCE-004 in resource ownership.

Class-authored cleanup

A class declares synchronous deterministic managed cleanup in a dispose ... end dispose block and asynchronous cleanup in an async dispose ... end dispose block. It may declare one of each. The compiler provides lifecycle guards and normal IDisposable/IAsyncDisposable plumbing for the declared protocols.

An optional nested unmanaged ... end unmanaged section identifies cleanup also required by CLR finalization. A class may declare at most one such section, inside either disposal block. It supplies the same unmanaged cleanup after either selected managed route and to that class's single synchronous finalizer fallback. Without unmanaged, a managed dispose block does not generate a finalizer merely because disposal exists.

The programmer does not author a separate destructor or manually call SuppressFinalize. CLR finalization remains synchronous. Imported IAsyncDisposable contracts retain their normal asynchronous cleanup behavior.

Declare asynchronous authored cleanup with async dispose ... end dispose. The compiler provides normal IAsyncDisposable plumbing. Write await for an ordinary cleanup call whose completion is required, or task_of to capture its operation deliberately. The async declaration fixes the public disposal protocol even if all paths happen to complete synchronously. Nested using scopes still select and await their cleanup automatically.

Plain dispose does not infer a different public interface from its body. Cleanup that requires suspension needs an explicit asynchronous contract; the compiler must not insert blocking cleanup. An unmanaged finalizer section remains synchronous, including when nested in async dispose. IAsyncDisposable does not gain a cancellation parameter or the async-action Cancellation context. Resource scopes select the appropriate disposal mechanism from the resource type.

One object, one cleanup lifecycle

When a class declares both protocols, invoking either route starts the same cleanup lifecycle. The chosen body must perform complete cleanup for that route, including inherited obligations. The compiler does not call the other authored body afterward. Put shared work in ordinary helper actions where useful.

Situation Result
First call uses Dispose Run synchronous cleanup once
First call uses DisposeAsync Run asynchronous cleanup once; its awaited completion determines the outcome
Cleanup succeeds, then either protocol is called again Complete without running another cleanup body
Another call arrives while cleanup is running or awaiting Throw InvalidOperationException for the additional call; leave the active attempt alone
A cleanup body recursively disposes its own object Throw InvalidOperationException for the nested request
Cleanup fails, then either protocol is called again Rethrow the same retained original failure; do not repeat partial effects or try the other route

An overlapping asynchronous call does not join the active disposal operation. A synchronous call does not block waiting for unfinished asynchronous cleanup. Rejecting an additional call does not itself change the active attempt's outcome. If that rejection propagates out of the active cleanup body, the active attempt fails normally.

A normally catchable cleanup failure is retained with its exception identity and propagation information. Repeated public calls reproduce that failure rather than pretending cleanup succeeded. This policy provides no retry/reset mechanism and no automatic cross-protocol fallback. An async-only declaration does not gain a synchronous implementation that blocks on it.

Share unmanaged cleanup across both routes

The section's location does not restrict it to that route. If it is nested in async dispose, a synchronous Dispose call still runs the synchronous managed body followed by the shared unmanaged fragment. It does not run the asynchronous managed body. DisposeAsync similarly completes its managed work before running the same fragment synchronously, even if the section was declared in plain dispose.

Path Managed cleanup Unmanaged cleanup
Successful first Dispose The synchronous body Shared fragment, after managed cleanup
Successful first DisposeAsync The asynchronous body, completed first Shared fragment, synchronously
Required finalizer fallback Neither managed body Shared fragment, synchronously
Repeated call after successful disposal None None
Repeated call after failed disposal None; rethrow retained failure No explicit retry; required finalization remains available

Keep the shared fragment usable from both routes and from finalization. It cannot await or rely on locals or parameters private to a managed body; store the necessary state in the class or use ordinary synchronous helpers. Declaring two unmanaged sections in the same class is invalid. Inherited cleanup obligations still apply; a derived class's fragment does not replace required base cleanup.

If managed cleanup or the shared fragment fails, retain the original public cleanup failure under the lifecycle rules above. Do not invoke the other managed body after that failure and do not suppress required unmanaged finalization. See CS-DISPOSE-008 in authored cleanup.

Finalization and implementation boundaries

The retained failure applies to public disposal requests. Required unmanaged finalization remains an independent fallback: failed cleanup must not suppress it, and a failed-state guard must not prevent the generated finalizer from performing still-required unmanaged cleanup. Successful disposal suppresses finalization normally. Inherited cleanup requirements remain applicable whichever route is invoked.

A reference C# strategy therefore needs coordinated not-started, in-progress, successful and failed states, plus retained exception-dispatch information. A single Boolean set before invoking user cleanup cannot distinguish these outcomes. The asynchronous route remains in progress until its cleanup actually completes. Finalizer eligibility must be handled separately from the cached public-call failure. These are lowering requirements, not an implemented runtime.

These generated lifecycle rules concern authored DirectSharp classes. Imported .NET objects keep their native cleanup behavior. Gate keeps its own contract: refusing disposal while permits are pending/held leaves it usable for a later disposal attempt. Resource scopes choose a protocol using the static-contract table above; that choice is separate from the cleanup lifecycle. See authored cleanup, especially CS-DISPOSE-004–008.

Best-effort value acquisition

let port = try_get Global.System.Int32.Parse(text) ?? 443

This action fragment returns the parsed Integer on success or nothing for any normally catchable acquisition exception; the fallback then supplies 443. The value alone does not retain failure detail, but LastAttempt records the completed attempt for observation in the containing block. Its initial record exists before the first try/try_get with Number 1, Exception nothing and HasErrors false. It provides no embedded exception variable, catch classification, retry workflow or automatic logging. A later completed try/try_get in that block replaces the metadata; nested blocks maintain separate state.

A multiline value computation requires one final take:

let customer = try_get
    let response = api.Get(id)
    Validate(response)
    take response.Customer
end try_get

The protected work includes the final expression. Normally catchable failure yields absence. Embedded catch, finally and retry are invalid; use structured try when those behaviors matter. To protect an asynchronous failure, put the await inside the protected computation. Capturing a task alone protects invocation, not a later task failure.

Blockless try statement instead attempts a statement and continues after normally catchable failure. It produces no value. Do not replace errors requiring operational handling with silent best effort.

Structured exceptions

try
    SaveInvoice(invoice)
catch ex as Global.System.TimeoutException
    LogTimeout(ex)
    QueueForRetry(invoice)
finally
    ReleaseTemporaryState()
end try

This application fragment illustrates classification and unconditional cleanup. A bare catch is catch-all. Specific catches must remain ordered so later handlers are reachable. Bare throw in a catch rethrows the current exception; it is not a fresh exception construction.

Required finally blocks complete before action-level postconditions. An exceptional exit skips ensure; a finally failure abandons normal completion. Do not lower postconditions into finally, which would run them on failure too.

Contracts and exceptions give default failure categories, retry and diagnostic obligations.