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
- Wrap the ref mutation in a transaction: `var result = STM.sync(() => { ref.Value = x; return ...; });`
- 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.
- 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
- Always perform Ref mutations inside the sync delegate, never capture-and-defer them.
- Prefer Atom<T> when transactional semantics are not needed.
- Add a code-review rule forbidding ref writes outside STM.sync blocks.
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
- Refs can only commute from within a transaction
- Transaction not running
- diff broken
- diff broken
- List is empty
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 acceptView on GitHub (pinned to 2f0e362824)