v5.x to v6.x
Three changes in v6 keep compiling and change what your code does. Read this page before you upgrade, and start with the two silent ones.
One step it cannot do for you. Option.Some(0) returns a Some in v6 where it used to throw, and Option<int> x = 0; gives you Some(0) where it used to give you None. Deciding whether an IsNone branch was standing in for "zero" needs someone who knows what the code means. See Silent change 3.
Read this first
v6 has three changes that do not break your build:
Option.Try(() => SomethingAsync())stops catching exceptions. It still compiles. It now returnsOption<Task<T>>.A cancelled operation inside
Trynow propagates instead of becomingNoneor anErr.Option.Some(0)returns aSomewhere it used to throw, andOption<int> x = 0;gives youSome(0)where it used to give youNone.
Everything else in v6 breaks loudly. The compiler will find it for you.
Work through the three sections below in order. The agent prompt above covers everything mechanical.
Silent change 1: Try with an async factory
This is the most dangerous change in the release. It compiles, it runs, and it silently stops handling exceptions.
v5 deprecated Option.Try(Func<Task<T>>) and Result.Try(Func<Task<TOk>>, Func<Exception, TErr>). v6 deletes them.
Deleting them does not leave you with a compiler error, because the synchronous overload can take their place. Option.Try<T>(Func<T>) constrains T to notnull, and a Task<int> is not null:
T binds to Task<int>. Try calls the factory, gets a task back, wraps it in a Some, and returns. It never awaits, so:
Your exception handling is gone. A throw inside
FetchCountAsyncescapes to your caller. It does not become aNoneor anErr, and your configured exception logger never sees it.The task may never be awaited, depending on what you do with the result.
You only get a compiler error if the call site assigns to an explicitly typed local:
The repair
Call TryAsync and await where you were already awaiting:
The analyzer finds these for you
WM1011 is a warning, not a suggestion, because it fires on code that runs. It reports any call that traps a task inside an Option or a Result — Try with an async factory, but also option.Map(x => FetchAsync(x)) and anything else with an Async sibling it should have used.
It ships with no quick fix, deliberately. Renaming to the Async sibling leaves you with an unawaited task, and no fix can decide where your await belongs.
Silent change 2: cancellation propagates
In v5, Try and TryAsync caught every exception, including OperationCanceledException. A cancelled operation came back as a None or an Err, indistinguishable from a genuine failure.
In v6 they let cancellation through:
TaskCanceledException derives from OperationCanceledException, so it propagates too.
Why we changed it
Cancellation is not a failure. It is you telling the operation to stop. Swallowing it turns a deliberate shutdown into what looks like a bad result, and the caller that requested the cancellation then has to guess whether the None it received means "cancelled" or "genuinely absent".
What to check
Look for code that relied on a cancellation becoming a None or an Err. It now needs a catch:
If you want the old behaviour
Opt back in once, at startup:
That restores the v5 behaviour everywhere: a cancellation is caught, logged, and becomes a None or an Err again. You can also scope it to one region with MonadOptions.BeginScope. See Configuration.
We recommend leaving it off. The opt-in exists so that upgrading is not blocked on rewriting every call site at once.
Silent change 3: Some accepts value-type defaults
In v5, a Some cannot hold the default of its type. Option.Some(0) throws, and Option<int> x = 0; gives you None.
In v6, only null is rejected. Option.Some(0) gives you a Some holding 0, and so does Option<int> x = 0;.
We made this change because Option<int> could not represent part of its own domain. Zero is an ordinary integer. Option<bool> was close to useless, because false is an ordinary bool.
The expressions that flip
Nothing fails to compile. No signature changes. Your build stays green and these expressions start returning a different value:
Option.Some(0)
throws
Some(0)
Option<int> x = someInt; when someInt is 0
None
Some(0)
Option.FromNullable(nullableInt) when the value is 0
None
Some(0)
Option.Try(() => ComputeCount()) when the count is 0
None
Some(0)
option.Map(x => x - x)
None
Some(0)
option.Reduce(...) producing a default
None
Some(default)
The same applies to false, '\0', Guid.Empty, DateTime.MinValue, DateTimeOffset.MinValue, TimeSpan.Zero, IntPtr.Zero and any enum's zero member.
What to do
Search for
Option<over a value type. Every one is a candidate.Check each
IsNonebranch on those options. Ask whether it treats a zero as "no result". If it does, that branch stops running in v6.Delete any
catch (InvalidOperationException)aroundOption.Some. It is dead code now.
There is no automatic migration, and the analyzer cannot do this for you. A rule that fired on every Option<T> where T is a value type would fire on most of the library's users.
WM1010 shipped in 5.5.0 to warn you about exactly this, and v6 removes it — the change it forecast has happened. Upgrade to 5.5.0 first, fix what WM1010 reports, then move to v6. It only reaches the call sites where it can prove the value is a default, so it covers none of the six expressions in the table above.
Null still throws, with a different exception
Option.Some(null!) throws in v6, as it did in v5. The exception type changes from InvalidOperationException to ArgumentNullException.
Use Option.FromNullable when the value may be null.
FromNullable<T>(T?) where T : struct no longer rejects the default either, so it now behaves the same way as its reference-type sibling.
UnwrapOrDefault gets harder to read
This follows directly from the relaxation. Both of these return 0:
You cannot tell them apart. This is not new in kind, since Result has always been in this position, but it now applies to every Option over a value type.
Use UnwrapOrNull and MapOrNull, shipped in 5.4.0, when you need to distinguish:
WM2015 points you at them.
Loud change: async extensions all return ValueTask
Every async extension on Option and Result now returns ValueTask or ValueTask<T>. 185 signatures changed. In v5 some returned Task and some returned ValueTask; now the rule is uniform.
You get CS0029 or CS1503 at every affected call site. Nothing changes silently.
Option.TryAsync and Result.TryAsync are the exception in v6. They are static factories rather than extensions, so they returned Task in v5 and still return Task in v6. Leave those call sites alone — adding .AsTask() there will not compile.
The repair
Add .AsTask() where you need a Task:
Your IDE offers this fix automatically. It registers against the compiler's own CS0029 and CS1503, so it appears wherever the error appears.
If you simply await the result, nothing changes at all.
Where this costs you
Task.WhenAll needs Task arguments, so fan-out code now has to call .AsTask() on each one — which allocates the exact Task the change avoids:
This is the one place the change makes your code worse.
The measured trade-off
ValueTask is not unconditionally cheaper. For a three-link chain:
Synchronous Option, Some
−144 B (−33%)
Synchronous Option, None
−144 B (−67%)
Already-completed Task
−216 B
Genuinely pending task
+84 B (+10%)
A fluent chain is built eagerly, so a pending head makes every link pending — there is no partial case. AsyncValueTaskMethodBuilder holds the state machine inline in its box, which makes it larger than the Task it replaces.
Full numbers are in bench/Waystone.Monads.Benchmarks/README.md in the source repository.
Loud change: FlatMap is gone
Option<T>.FlatMap and the five FlatMapAsync extensions are deleted. Rename to AndThen and AndThenAsync:
The parameters, the behaviour and the return type are unchanged.
WM2014, the rule that reported every FlatMap call, is removed in v6 — with no FlatMap left it would report nothing forever. Upgrade to 5.5.0 first and let it build your to-do list.
Loud change: you can no longer derive from Option or Result
Option<T> and Result<TOk, TErr> are closed. Some, None, Ok and Err are the only cases, and an outside type that tries to add a third gets CS0534 on an internal member it cannot see or override.
There is no way around this and no migration path. If you added a case, you have to compose the monad instead of inheriting it — hold an Option<T> in your type rather than being one.
WM1007, the v5 warning that told you not to derive, is gone in v6 because the compiler now says it instead.
Six members moved from the base type to the cases and are now abstract:
Option.And,Option.MapOrDefault,Option.Reduce,Option.AsEnumerableResult.MapOrDefault,Result.AsEnumerable
Behaviour is identical. This only affects anyone who overrode them on a derived case — the same people the closed hierarchy already stops.
Fixed: Unzip no longer throws on a defaulted component
Option.Some((0, "x")).Unzip() threw in v5. It now returns (Some(0), Some("x")).
This falls out of the Some relaxation. We considered returning None for the defaulted component instead and rejected it: UnwrapOr(-1) would then hand back -1 for a 0 that was genuinely there.
If you wrote defensive code around the old throw, delete it rather than re-pointing it.
Changed: two Nones of the same type are now the same object
None<T> is a cached singleton in v6, so Option.None<int>() returns the same instance every time.
Equality and hashing are unchanged — two None values were already equal, and still are. Only ReferenceEquals answers differently.
This removes an allocation from every operation that produces a None: Option.None<int>() went from 1.65 ns and 24 B to 0.18 ns and 0 B.
New: state overloads that avoid a closure
Every hot-path transform gained a sibling that takes your data as an argument and hands it to the delegate, so the delegate captures nothing:
This is purely additive. No existing signature changed and there is nothing to migrate.
Covered methods, as at 6.0:
Option:Map,MapOr,MapOrElse,Filter,AndThenResult:Map,MapOr,MapOrElse,MapErr,AndThenOption.Try,Option.TryAsync,Result.Try,Result.TryAsync
Later 6.x releases added more. See Where you can use it for the current set.
The closure costs exactly 88 bytes at every call site: 24 for the display class, 64 for the delegate. The state overload removes all of it.
See State overloads for the detail, including why the static keyword matters.
WM2017 points you at these when it sees a delegate that captures.
Analyzer rules that changed
Removed
WM1004
Described the default-value invariant, which no longer exists
WM1007
Deriving from Option or Result is a compile error now
WM1009
Option<bool> is genuinely useful in v6, so the advice is withdrawn
WM1010
Forecast the Some relaxation, which has now happened
WM2014
There is no FlatMap left to report
None of these IDs will be reused. If you suppressed one in .editorconfig, drop the entry.
Added
Reworded
WM1001 and WM1005 now describe Some as rejecting null rather than rejecting the default of the type, and WM1001 names ArgumentNullException. WM2015 now names the value it hands back. No behaviour changed in any of the three.
Everything on one page
Try with an async factory
No
Switch to TryAsync and await
Cancellation propagates
No
Catch it, or UseCancellationAsFailure()
Some accepts value-type defaults
No
Review every IsNone on a value type
Async extensions return ValueTask
Yes
Add .AsTask()
FlatMap removed
Yes
Rename to AndThen
Hierarchies closed
Yes
Compose instead of inherit
Unzip fixed
No
Delete defensive code
None<T> is a singleton
No
Nothing, unless you used ReferenceEquals
State overloads added
No
Nothing — adopt them where it helps
Last updated
Was this helpful?