louthy/language-ext · error · InvalidOperationException

Refs can only be written to from within a `sync` transaction

Error message

Refs can only be written to from within a `sync` transaction

What it means

LanguageExt's STM (software transactional memory) `Ref<T>` values can only be mutated inside a transaction started via `sync(...)` or `atomic(...)`. `STM.Write` is the internal write path for refs; it checks the ambient (async-local) transaction and throws if none is running. This mirrors Clojure's rule that refs are transactional and cannot be mutated outside a `dosync` block.

Solutions

  1. Wrap the ref mutation in a transaction: `var result = STM.sync(() => { ref.Value = x; return ...; });`
  2. If you don't need transactional semantics, use a plain mutable holder (e.g. `Ref` replaced by a simple class field, `Atom<T>`, or `Option` in a field) instead of a STM Ref.
  3. Check that the code path actually executes inside `sync` — async/await boundaries or thread switches can drop the transaction context; do all ref writes within the sync delegate.

Example fix

// before
var r = Ref.create(0);
r.Value = 42; // throws: no transaction

// after
var r = Ref.create(0);
STM.sync(() => { r.Value = 42; return unit; });
Defensive patterns

Strategy: try-catch

Validate before calling

var insideTx = STM.TransactionRunning; // or track your own flag set inside sync
if (!insideTx) throw new InvalidOperationException("Ref write requires STM.sync");

Type guard

bool CanWriteRef() => !IsOutsideTransaction(); // expose your own ambient-transaction check

Try / catch

try { STM.sync(() => { r.Value = x; return unit; }); }
catch (InvalidOperationException ex) when (ex.Message.Contains("sync")) { /* fallback: use Atom or plain field */ }

Prevention

When it happens

Trigger: Calling `ref.Value = x` (or any ref mutation API that routes to STM.Write) from code that is not running inside an `STM.sync`/`atomic` block — e.g. mutating a Ref from a background thread, event handler, or top-level code where no transaction is active.

Common situations: Developers treat Ref<T> like a plain mutable cell and set its value outside of any `sync` block; mutating refs in async continuations where the transaction context was lost; unit tests that construct a Ref and write to it directly without wrapping in a transaction.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of louthy/language-ext@2f0e362824 (2026-09-15). Data as JSON: /api/errors/cd24964ae418b96f. Report an issue: GitHub.

Appendix: source

Thrown at LanguageExt.Core/Concurrency/STM/STM.cs:403

    /// If within a transaction then the in-transaction value is returned, otherwise it's
    /// the current latest value
    /// </summary>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    internal static object Read(long id) =>
        transaction.Value == null
            ? state.Items[id].UntypedValue
            : transaction.Value.ReadValue(id);

    /// <summary>
    /// Write the value for the reference ID provided
    /// Must be run within a transaction
    /// </summary>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    internal static void Write(long id, object value)
    {
        if (transaction.Value == null)
        {
            throw new InvalidOperationException("Refs can only be written to from within a `sync` transaction");
        }
        transaction.Value.WriteValue(id, value);
    }

    /// <summary>
    /// Must be called in a transaction. Sets the in-transaction-value of
    /// ref to:  
    /// 
    ///     `f(in-transaction-value-of-ref)`
    ///     
    /// and returns the in-transaction-value when complete.
    /// 
    /// At the commit point of the transaction, `f` is run *AGAIN* with the
    /// most recently committed value:
    /// 
    ///     `f(most-recently-committed-value-of-ref)`
    /// 
    /// Thus `f` should be commutative, or, failing that, you must accept

View on GitHub (pinned to 2f0e362824)