diff --git a/Directory.Packages.props b/Directory.Packages.props
index d1660712..9ff52b83 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -33,6 +33,7 @@
+
diff --git a/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs b/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs
index c6f5af3c..3b61d767 100644
--- a/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs
+++ b/FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs
@@ -1,4 +1,5 @@
using System.Collections.Immutable;
+using System.Reflection;
using FirstClassErrors;
@@ -9,18 +10,44 @@
namespace FirstClassErrors.Analyzers.UnitTests;
///
-/// Minimal in-process harness: compiles a C# snippet against the running runtime plus the FirstClassErrors core,
-/// runs a single analyzer over it, and returns the analyzer diagnostics. Deliberately dependency-free (no
-/// Microsoft.CodeAnalysis.Testing) so it composes cleanly with xUnit v3 and NFluent.
+/// Minimal in-process harness: compiles a C# snippet against a reference set, runs a single analyzer over it, and
+/// returns the analyzer diagnostics. Deliberately dependency-free (no Microsoft.CodeAnalysis.Testing) so it composes
+/// cleanly with xUnit v3 and NFluent.
///
+///
+/// compiles against the running runtime, the default for the analyzer suite.
+/// compiles against the .NET Framework 4.7.2 reference assemblies
+/// instead — the analyzed code's target framework, not the test's runtime, is what a framework-aware rule
+/// reacts to — so a rule that resolves a counterpart from the compilation (FCE021) can be proven silent where that
+/// counterpart does not exist for an older framework.
+///
internal static class AnalyzerTestHarness {
+ private const string Net472ReferenceAssembliesMetadataKey = "Net472ReferenceAssemblies";
+
private static readonly ImmutableArray BaseReferences = BuildBaseReferences();
- public static async Task> GetDiagnosticsAsync(
- DiagnosticAnalyzer analyzer,
- string source,
- params string[] enabledDiagnosticIds) {
+ public static Task> GetDiagnosticsAsync(
+ DiagnosticAnalyzer analyzer,
+ string source,
+ params string[] enabledDiagnosticIds) {
+
+ return RunAsync(analyzer, source, BaseReferences, enabledDiagnosticIds);
+ }
+
+ public static Task> GetDiagnosticsAgainstNet472Async(
+ DiagnosticAnalyzer analyzer,
+ string source,
+ params string[] enabledDiagnosticIds) {
+
+ return RunAsync(analyzer, source, BuildNet472References(), enabledDiagnosticIds);
+ }
+
+ private static async Task> RunAsync(
+ DiagnosticAnalyzer analyzer,
+ string source,
+ ImmutableArray references,
+ string[] enabledDiagnosticIds) {
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(source);
@@ -35,7 +62,7 @@ public static async Task> GetDiagnosticsAsync(
CSharpCompilation compilation = CSharpCompilation.Create(
assemblyName: "FirstClassErrors.Analyzers.TestSnippet",
syntaxTrees: new[] { syntaxTree },
- references: BaseReferences,
+ references: references,
options: options);
CompilationWithAnalyzers withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer));
@@ -57,10 +84,42 @@ private static ImmutableArray BuildBaseReferences() {
}
}
- // The FirstClassErrors core, so ErrorCode / DomainError / DescribeError resolve inside the snippet.
- references.Add(MetadataReference.CreateFromFile(typeof(ErrorCode).Assembly.Location));
+ AddCore(references);
return references.ToImmutableArray();
}
+ private static ImmutableArray BuildNet472References() {
+ List references = new();
+
+ // The .NET Framework 4.7.2 reference assemblies (including the netstandard facade, so the netstandard2.0 core
+ // resolves). The directory is baked in at build time via the Net472ReferenceAssemblies assembly metadata.
+ foreach (string dll in Directory.EnumerateFiles(Net472ReferenceDirectory(), "*.dll", SearchOption.AllDirectories)) {
+ try {
+ references.Add(MetadataReference.CreateFromFile(dll));
+ } catch {
+ // Skip anything Roslyn cannot read as a metadata reference.
+ }
+ }
+
+ AddCore(references);
+
+ return references.ToImmutableArray();
+ }
+
+ private static void AddCore(List references) {
+ // The FirstClassErrors core, so Outcome / ErrorCode / DomainError resolve inside the snippet.
+ references.Add(MetadataReference.CreateFromFile(typeof(ErrorCode).Assembly.Location));
+ }
+
+ private static string Net472ReferenceDirectory() {
+ foreach (AssemblyMetadataAttribute attribute in typeof(AnalyzerTestHarness).Assembly.GetCustomAttributes()) {
+ if (attribute.Key == Net472ReferenceAssembliesMetadataKey && !string.IsNullOrWhiteSpace(attribute.Value)) {
+ return attribute.Value!;
+ }
+ }
+
+ throw new InvalidOperationException($"The '{Net472ReferenceAssembliesMetadataKey}' assembly metadata was not found; the net472 reference-assemblies package is not wired into the test project.");
+ }
+
}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce019TryCatchesTooBroadlyTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce019TryCatchesTooBroadlyTests.cs
new file mode 100644
index 00000000..0064bd8b
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce019TryCatchesTooBroadlyTests.cs
@@ -0,0 +1,111 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce019TryCatchesTooBroadlyTests {
+
+ [Fact]
+ public async Task Reports_when_the_value_overload_catches_Exception() {
+ const string source = """
+ using System;
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M() {
+ return Outcome.Try(() => 1, _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesTooBroadlyAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE019");
+ }
+
+ [Fact]
+ public async Task Reports_when_the_void_overload_catches_Exception() {
+ const string source = """
+ using System;
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static void M() {
+ Outcome.Try(() => { }, _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesTooBroadlyAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE019");
+ }
+
+ [Fact]
+ public async Task Reports_when_the_async_overload_catches_Exception() {
+ const string source = """
+ using System;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Task> M() {
+ return Outcome.Try(ct => Task.FromResult(1), _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesTooBroadlyAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE019");
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_a_specific_exception_is_caught() {
+ const string source = """
+ using System;
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M() {
+ return Outcome.Try(() => 1, _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesTooBroadlyAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_a_Try_that_is_not_on_Outcome() {
+ const string source = """
+ using System;
+
+ public static class Other {
+ public static void Try(Action action) where TException : Exception { }
+ }
+
+ public static class Sample {
+ public static void M() {
+ Other.Try(() => { });
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesTooBroadlyAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce020TryCatchesRichProtocolExceptionTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce020TryCatchesRichProtocolExceptionTests.cs
new file mode 100644
index 00000000..19cce269
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce020TryCatchesRichProtocolExceptionTests.cs
@@ -0,0 +1,108 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce020TryCatchesRichProtocolExceptionTests {
+
+ [Fact]
+ public async Task Reports_when_Try_catches_HttpRequestException() {
+ const string source = """
+ using System.Net.Http;
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M() {
+ return Outcome.Try(() => 1, _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesRichProtocolExceptionAnalyzer(), source, "FCE020");
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE020");
+ }
+
+ [Fact]
+ public async Task Reports_when_Try_catches_a_subtype_of_a_protocol_exception() {
+ const string source = """
+ using System.Net.Http;
+ using FirstClassErrors;
+
+ public class TransportException : HttpRequestException { }
+
+ public static class Sample {
+ public static Outcome M() {
+ return Outcome.Try(() => 1, _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesRichProtocolExceptionAnalyzer(), source, "FCE020");
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE020");
+ }
+
+ [Fact]
+ public async Task Reports_when_Try_catches_SocketException() {
+ const string source = """
+ using System.Net.Sockets;
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M() {
+ return Outcome.Try(() => 1, _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesRichProtocolExceptionAnalyzer(), source, "FCE020");
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE020");
+ }
+
+ [Fact]
+ public async Task Does_not_report_a_domain_exception() {
+ const string source = """
+ using System;
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M() {
+ return Outcome.Try(() => 1, _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesRichProtocolExceptionAnalyzer(), source, "FCE020");
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_a_broad_Exception_which_is_FCE019s_concern() {
+ const string source = """
+ using System;
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M() {
+ return Outcome.Try(() => 1, _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesRichProtocolExceptionAnalyzer(), source, "FCE020");
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce021FrameworkAwarenessTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce021FrameworkAwarenessTests.cs
new file mode 100644
index 00000000..a727b570
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce021FrameworkAwarenessTests.cs
@@ -0,0 +1,59 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+///
+/// Proves FCE021's framework-awareness end-to-end by compiling the analyzed snippet against the .NET Framework 4.7.2
+/// reference assemblies (not the running runtime). A framework-aware rule reacts to the analyzed code's target
+/// framework, so the same call can be flagged on a modern target and left alone on an older one.
+///
+public class Fce021FrameworkAwarenessTests {
+
+ [Fact]
+ public async Task Reports_int_Parse_against_the_net472_reference_set_because_int_TryParse_exists_there() {
+ // Control: int.TryParse ships on net472 too, so this still fires against the net472 reference set. It proves the
+ // compilation resolves and the analyzer runs — which is what makes the silent result below meaningful rather
+ // than a setup artefact.
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M(string raw) {
+ return Outcome.Try(() => int.Parse(raw), _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAgainstNet472Async(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE021");
+ }
+
+ [Fact]
+ public async Task Does_not_report_new_MailAddress_against_the_net472_reference_set_where_TryCreate_does_not_exist() {
+ // The marquee claim: MailAddress.TryCreate is .NET 5+, so against the net472 reference set no counterpart
+ // resolves and the rule stays silent — even though the very same call fires on net10 (see the MailAddress case
+ // in Fce021PreferNonThrowingAlternativeToTryTests.Reports_a_constructor_that_has_a_matching_counterpart).
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M(string raw) {
+ return Outcome.Try(() => new System.Net.Mail.MailAddress(raw), _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAgainstNet472Async(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce021PreferNonThrowingAlternativeToTryTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce021PreferNonThrowingAlternativeToTryTests.cs
new file mode 100644
index 00000000..e1d08963
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce021PreferNonThrowingAlternativeToTryTests.cs
@@ -0,0 +1,418 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce021PreferNonThrowingAlternativeToTryTests {
+
+ #region Reports — a throwing call whose non-throwing counterpart resolves (regardless of origin)
+
+ [Theory]
+ [InlineData("int", "int.Parse(raw)")]
+ [InlineData("double", "double.Parse(raw)")]
+ [InlineData("System.Guid", "System.Guid.Parse(raw)")]
+ [InlineData("System.DateTime", "System.DateTime.Parse(raw)")]
+ [InlineData("System.TimeSpan", "System.TimeSpan.Parse(raw)")]
+ [InlineData("System.Net.IPAddress", "System.Net.IPAddress.Parse(raw)")]
+ public async Task Reports_a_BCL_parse_that_has_a_matching_TryParse(string resultType, string call) {
+ string source = $$"""
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome<{{resultType}}> M(string raw) {
+ return Outcome.Try<{{resultType}}, System.FormatException>(() => {{call}}, _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE021");
+ }
+
+ [Theory]
+ [InlineData("System.Guid", "new System.Guid(raw)")] // ctor -> TryParse
+ [InlineData("System.Version", "new System.Version(raw)")] // ctor -> TryParse
+ [InlineData("System.Uri", "new System.Uri(raw, System.UriKind.Absolute)")] // ctor -> TryCreate
+ [InlineData("System.Net.Mail.MailAddress", "new System.Net.Mail.MailAddress(raw)")] // ctor -> TryCreate
+ public async Task Reports_a_constructor_that_has_a_matching_counterpart(string resultType, string call) {
+ string source = $$"""
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome<{{resultType}}> M(string raw) {
+ return Outcome.Try<{{resultType}}, System.FormatException>(() => {{call}}, _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE021");
+ }
+
+ [Fact]
+ public async Task Reports_a_block_bodied_lambda_that_returns_a_single_call() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M(string raw) {
+ return Outcome.Try(() => { return int.Parse(raw); }, _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE021");
+ }
+
+ [Fact]
+ public async Task Reports_a_generic_parse_by_constructing_the_counterpart() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M(string raw) {
+ return Outcome.Try(() => System.Enum.Parse(raw), _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE021");
+ }
+
+ [Fact]
+ public async Task Reports_on_a_user_defined_method_with_a_matching_TryParse() {
+ // The rule no longer cares where the type is declared: a consumer type with a shape-matching pair is flagged too.
+ const string source = """
+ using FirstClassErrors;
+
+ public struct Temperature {
+ public static Temperature Parse(string s) => default;
+ public static bool TryParse(string s, out Temperature value) { value = default; return true; }
+ }
+
+ public static class Sample {
+ public static Outcome M(string raw) {
+ return Outcome.Try(() => Temperature.Parse(raw), _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE021");
+ }
+
+ [Fact]
+ public async Task Reports_on_a_user_defined_constructor_with_a_matching_TryCreate() {
+ // Fires even though this TryCreate may not be a behaviour-preserving inverse — that is why the rule is advisory
+ // and suppressible; the developer confirms and silences it where it does not fit.
+ const string source = """
+ using FirstClassErrors;
+
+ public sealed class Slug {
+ public Slug(string value) { }
+ public static bool TryCreate(string value, out Slug result) { result = new Slug(value); return true; }
+ }
+
+ public static class Sample {
+ public static Outcome M(string raw) {
+ return Outcome.Try(() => new Slug(raw), _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE021");
+ }
+
+ [Fact]
+ public async Task Reports_by_default_without_an_explicit_opt_in() {
+ // FCE021 is on by default (Warning); no .editorconfig opt-in is needed for it to fire.
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M(string raw) {
+ return Outcome.Try(() => int.Parse(raw), _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ }
+
+ [Fact]
+ public async Task Reports_a_message_naming_both_the_call_and_its_counterpart() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M(string raw) {
+ return Outcome.Try(() => int.Parse(raw), _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].GetMessage()).Contains("int.Parse").And.Contains("int.TryParse");
+ }
+
+ #endregion
+
+ #region Does not report — no compatible counterpart resolves
+
+ [Fact]
+ public async Task Does_not_report_a_call_with_no_counterpart() {
+ const string source = """
+ using FirstClassErrors;
+
+ public struct Gadget {
+ public static Gadget Parse(string s) => default;
+ }
+
+ public static class Sample {
+ public static Outcome M(string raw) {
+ return Outcome.Try(() => Gadget.Parse(raw), _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_no_exact_arity_TryParse_exists() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M(string raw) {
+ return Outcome.Try(() => int.Parse(raw, System.Globalization.NumberStyles.Integer), _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_the_counterpart_signature_does_not_match() {
+ const string source = """
+ using FirstClassErrors;
+
+ public struct Foo {
+ public static Foo Parse(string s) => default;
+ public static bool TryParse(string s) => true; // no out result: not a drop-in
+ }
+
+ public static class Sample {
+ public static Outcome M(string raw) {
+ return Outcome.Try(() => Foo.Parse(raw), _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_the_counterpart_is_an_instance_method() {
+ const string source = """
+ using FirstClassErrors;
+
+ public struct Thing {
+ public static Thing Parse(string s) => default;
+ public bool TryParse(string s, out Thing value) { value = default; return true; } // instance, not static
+ }
+
+ public static class Sample {
+ public static Outcome M(string raw) {
+ return Outcome.Try(() => Thing.Parse(raw), _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ #endregion
+
+ #region Does not report — the wrapped operation is not a single, static, exact call
+
+ [Fact]
+ public async Task Does_not_report_an_instance_throwing_call() {
+ // A static counterpart cannot carry the receiver, so an instance call is never a one-for-one rewrite.
+ const string source = """
+ using FirstClassErrors;
+
+ public class Parser {
+ public int Read(string s) => 0;
+ public static bool TryRead(string s, out int value) { value = 0; return true; }
+ }
+
+ public static class Sample {
+ public static Outcome M(Parser parser, string raw) {
+ return Outcome.Try(() => parser.Read(raw), _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_a_construction_with_an_object_initializer() {
+ const string source = """
+ using FirstClassErrors;
+
+ public class Widget {
+ public int Tag;
+ public Widget(string s) { }
+ public static bool TryCreate(string s, out Widget value) { value = new Widget(s); return true; }
+ }
+
+ public static class Sample {
+ public static Outcome M(string raw) {
+ return Outcome.Try(() => new Widget(raw) { Tag = 1 }, _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_a_lambda_with_more_than_one_statement() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M(string raw) {
+ return Outcome.Try(() => {
+ string trimmed = raw.Trim();
+ return int.Parse(trimmed);
+ }, _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_a_call_wrapped_in_a_cast() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M(string raw) {
+ return Outcome.Try(() => (int)long.Parse(raw), _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ #endregion
+
+ #region Does not report — wrong overload or not Outcome.Try
+
+ [Fact]
+ public async Task Does_not_report_the_async_overload() {
+ const string source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Task> M(string raw) {
+ return Outcome.Try(ct => Task.FromResult(int.Parse(raw)), _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_the_void_overload() {
+ const string source = """
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M(string raw) {
+ return Outcome.Try(() => System.Console.WriteLine(int.Parse(raw)), _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_a_Try_that_is_not_on_Outcome() {
+ const string source = """
+ using System;
+
+ public static class Other {
+ public static T Try(Func operation, Func onError) where TException : Exception => operation();
+ }
+
+ public static class Sample {
+ public static int M(string raw) {
+ return Other.Try(() => int.Parse(raw), _ => null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new PreferNonThrowingAlternativeToTryAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ #endregion
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/Fce022TryCatchesCancellationTests.cs b/FirstClassErrors.Analyzers.UnitTests/Fce022TryCatchesCancellationTests.cs
new file mode 100644
index 00000000..ce7aadbe
--- /dev/null
+++ b/FirstClassErrors.Analyzers.UnitTests/Fce022TryCatchesCancellationTests.cs
@@ -0,0 +1,171 @@
+using System.Collections.Immutable;
+
+using FirstClassErrors.Analyzers;
+
+using Microsoft.CodeAnalysis;
+
+using NFluent;
+
+namespace FirstClassErrors.Analyzers.UnitTests;
+
+public class Fce022TryCatchesCancellationTests {
+
+ [Fact]
+ public async Task Reports_when_the_value_overload_catches_OperationCanceledException() {
+ const string source = """
+ using System;
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M() {
+ return Outcome.Try(() => 1, _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesCancellationAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE022");
+ }
+
+ [Fact]
+ public async Task Reports_when_the_caught_type_is_a_subtype_of_OperationCanceledException() {
+ // TaskCanceledException : OperationCanceledException, so the same 'is not OperationCanceledException' filter
+ // rules it out too — the catch is just as unreachable.
+ const string source = """
+ using System.Threading.Tasks;
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M() {
+ return Outcome.Try(() => 1, _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesCancellationAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE022");
+ }
+
+ [Fact]
+ public async Task Reports_when_the_void_overload_catches_OperationCanceledException() {
+ const string source = """
+ using System;
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static void M() {
+ Outcome.Try(() => { }, _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesCancellationAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE022");
+ }
+
+ [Fact]
+ public async Task Reports_when_the_async_overload_catches_OperationCanceledException() {
+ const string source = """
+ using System;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Task> M() {
+ return Outcome.Try(ct => Task.FromResult(1), _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesCancellationAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].Id).IsEqualTo("FCE022");
+ }
+
+ [Fact]
+ public async Task Names_the_caught_type_in_the_message() {
+ const string source = """
+ using System.Threading.Tasks;
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M() {
+ return Outcome.Try(() => 1, _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesCancellationAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(1);
+ Check.That(diagnostics[0].GetMessage()).Contains("TaskCanceledException");
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_the_base_Exception_is_caught() {
+ // Exception is a base of OperationCanceledException, not a subtype, so the filter can still engage for every
+ // non-cancellation exception. FCE019 owns that case (too-broad); FCE022 stays silent.
+ const string source = """
+ using System;
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M() {
+ return Outcome.Try(() => 1, _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesCancellationAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_when_an_unrelated_exception_is_caught() {
+ const string source = """
+ using System;
+ using FirstClassErrors;
+
+ public static class Sample {
+ public static Outcome M() {
+ return Outcome.Try(() => 1, _ => (Error)null);
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesCancellationAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+ [Fact]
+ public async Task Does_not_report_a_Try_that_is_not_on_Outcome() {
+ const string source = """
+ using System;
+
+ public static class Other {
+ public static void Try(Action action) where TException : Exception { }
+ }
+
+ public static class Sample {
+ public static void M() {
+ Other.Try(() => { });
+ }
+ }
+ """;
+
+ ImmutableArray diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesCancellationAnalyzer(), source);
+
+ Check.That(diagnostics.Length).IsEqualTo(0);
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj b/FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj
index cf3ef08c..d6f58260 100644
--- a/FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj
+++ b/FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj
@@ -20,6 +20,20 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ build;buildTransitive
+
+
+
+
+
+ <_Parameter1>Net472ReferenceAssemblies
+ <_Parameter2>$(PkgMicrosoft_NETFramework_ReferenceAssemblies_net472)/build/.NETFramework/v4.7.2
+
diff --git a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md
index 5c138876..a9fdc372 100644
--- a/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md
+++ b/FirstClassErrors.Analyzers/AnalyzerReleases.Unshipped.md
@@ -23,3 +23,7 @@ FCE015 | FirstClassErrors.DocumentationContent| Disabled | DocumentationTitleTo
FCE016 | FirstClassErrors.Usage | Warning | UnusedToExceptionResultAnalyzer
FCE017 | FirstClassErrors.Usage | Disabled | SensitiveDataInErrorContextAnalyzer
FCE018 | FirstClassErrors.Usage | Disabled | OversizedErrorContextValueAnalyzer
+FCE019 | FirstClassErrors.Usage | Warning | TryCatchesTooBroadlyAnalyzer
+FCE020 | FirstClassErrors.Usage | Disabled | TryCatchesRichProtocolExceptionAnalyzer
+FCE021 | FirstClassErrors.Usage | Warning | PreferNonThrowingAlternativeToTryAnalyzer
+FCE022 | FirstClassErrors.Usage | Warning | TryCatchesCancellationAnalyzer
diff --git a/FirstClassErrors.Analyzers/Descriptors.cs b/FirstClassErrors.Analyzers/Descriptors.cs
index 6f962534..1694770f 100644
--- a/FirstClassErrors.Analyzers/Descriptors.cs
+++ b/FirstClassErrors.Analyzers/Descriptors.cs
@@ -190,4 +190,44 @@ internal static class Descriptors {
description: "Error context should hold small, serializable facts that log cleanly. A key typed as a byte array, a Stream or a FileInfo carries a whole file or buffer into every log line and error-catalog entry, bloating output and often smuggling sensitive data along with it. Detection is based on the key's declared value type. Opt-in.",
helpLinkUri: HelpLinks.For(DiagnosticIds.OversizedErrorContextValue));
+ public static readonly DiagnosticDescriptor TryCatchesTooBroadly = new(
+ id: DiagnosticIds.TryCatchesTooBroadly,
+ title: "Outcome.Try catches a too-broad exception type",
+ messageFormat: "Outcome.Try catches '{0}'; catch the specific exception the operation is documented to throw, not a near-root type that also swallows bugs",
+ category: DiagnosticCategories.Usage,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "Outcome.Try is meant to catch the single exception type that denotes an anticipated failure and let everything else propagate. Catching System.Exception turns unexpected bugs (a null dereference, an invalid state) into anticipated errors and defeats the purpose of Outcome. Name the specific exception instead; if a boundary genuinely must map every failure, do it explicitly rather than through Try.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.TryCatchesTooBroadly));
+
+ public static readonly DiagnosticDescriptor TryCatchesRichProtocolException = new(
+ id: DiagnosticIds.TryCatchesRichProtocolException,
+ title: "Outcome.Try catches a protocol failure that carries more than the exception",
+ messageFormat: "Outcome.Try catches '{0}', a protocol failure carrying status or result data beyond the exception; review whether a dedicated adapter should inspect the result and preserve that structured information",
+ category: DiagnosticCategories.Usage,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: false,
+ description: "HTTP, socket and database failures are not fully described by the exception: a status code, a provider error number or a response body carries the real signal, one exception type spans several distinct failures with different transience, and a request timeout surfaces as an OperationCanceledException that Try lets through. Outcome.Try can reduce all of that to 'it threw'; the mapper can still read the status off the caught exception, but needing to is a sign the failure wants a dedicated result-inspecting adapter. Advisory and opt-in.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.TryCatchesRichProtocolException));
+
+ public static readonly DiagnosticDescriptor TryCatchesCancellation = new(
+ id: DiagnosticIds.TryCatchesCancellation,
+ title: "Outcome.Try catches a cancellation type, making the catch unreachable",
+ messageFormat: "Outcome.Try catches '{0}', a cancellation type; Outcome.Try always lets cancellation propagate, so this catch is unreachable and the mapper never runs",
+ category: DiagnosticCategories.Usage,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "Outcome.Try guards its catch with 'when (exception is not OperationCanceledException)' so a cancellation is never turned into an error — it always propagates. Binding TException to OperationCanceledException (or a subtype such as TaskCanceledException) therefore produces a catch that can never engage: the exception filter becomes a contradiction ('is an OperationCanceledException and is not one'), the mapper never runs, and no Outcome is produced. This is always a mistake, and it is silent (an always-false filter is not a compile error). Cancellation cannot be modelled as a Try failure: remove the cancellation handling from Try, or catch a specific non-cancellation exception.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.TryCatchesCancellation));
+
+ public static readonly DiagnosticDescriptor PreferNonThrowingAlternativeToTry = new(
+ id: DiagnosticIds.PreferNonThrowingAlternativeToTry,
+ title: "Outcome.Try wraps an operation that has a non-throwing alternative",
+ messageFormat: "Outcome.Try wraps '{0}'; a non-throwing '{1}' is available — consider mapping its result instead of catching",
+ category: DiagnosticCategories.Usage,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "When a wrapped call already has a non-throwing counterpart — a 'bool TryParse(..., out T)' or 'TryCreate' with a matching drop-in signature — there is usually no exception worth catching, so Outcome.Try adds cost and hides the cheaper path. It fires wherever such a counterpart resolves, regardless of where the wrapped type is declared. The rule is framework-aware: it fires only when the counterpart actually resolves, with a compatible signature, in the compilation being analyzed, so a target framework that lacks it (older .NET Standard / .NET Framework, where MailAddress.TryCreate or Convert.TryFromBase64String do not exist) is never flagged. It is an advisory (a suggestion, not an equivalence claim): a structurally-matching TryXxx may still normalize its input, diverge on culture, or report a different set of failures than the exception you catch (int.Parse also throws on overflow, which int.TryParse folds into false), and the TryXxx form has no exception to hand your mapper — so confirm it behaves identically before rewriting, and suppress the rule (SuppressMessage / #pragma) where it does not fit. Detection is limited to a single static call or a constructor in the lambda body, whose signature the counterpart must match exactly.",
+ helpLinkUri: HelpLinks.For(DiagnosticIds.PreferNonThrowingAlternativeToTry));
+
}
diff --git a/FirstClassErrors.Analyzers/DiagnosticIds.cs b/FirstClassErrors.Analyzers/DiagnosticIds.cs
index 284f83c0..19bd5a03 100644
--- a/FirstClassErrors.Analyzers/DiagnosticIds.cs
+++ b/FirstClassErrors.Analyzers/DiagnosticIds.cs
@@ -31,5 +31,9 @@ internal static class DiagnosticIds {
public const string UnusedToExceptionResult = "FCE016";
public const string SensitiveDataInErrorContext = "FCE017";
public const string OversizedErrorContextValue = "FCE018";
+ public const string TryCatchesTooBroadly = "FCE019";
+ public const string TryCatchesRichProtocolException = "FCE020";
+ public const string PreferNonThrowingAlternativeToTry = "FCE021";
+ public const string TryCatchesCancellation = "FCE022";
}
diff --git a/FirstClassErrors.Analyzers/OutcomeTryFacts.cs b/FirstClassErrors.Analyzers/OutcomeTryFacts.cs
new file mode 100644
index 00000000..119499df
--- /dev/null
+++ b/FirstClassErrors.Analyzers/OutcomeTryFacts.cs
@@ -0,0 +1,45 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace FirstClassErrors.Analyzers;
+
+///
+/// Symbol-inspection helpers shared by the Outcome.Try usage analyzers (FCE019, FCE020, FCE022). Every
+/// Try overload declares the caught exception through a type parameter named TException; these
+/// helpers recognize a call to FirstClassErrors.Outcome.Try<...> and surface the concrete type bound
+/// to it.
+///
+internal static class OutcomeTryFacts {
+
+ public const string OutcomeMetadataName = "FirstClassErrors.Outcome";
+
+ private const string TryMethodName = "Try";
+ private const string ExceptionTypeParameterName = "TException";
+
+ ///
+ /// Determines whether is a call to FirstClassErrors.Outcome.Try and, if so,
+ /// yields the concrete type bound to its TException type parameter.
+ ///
+ public static bool TryGetCaughtExceptionType(
+ IInvocationOperation invocation,
+ INamedTypeSymbol outcomeType,
+ out ITypeSymbol? caughtExceptionType) {
+
+ caughtExceptionType = null;
+
+ IMethodSymbol method = invocation.TargetMethod;
+ if (method.Name != TryMethodName) { return false; }
+ if (!SymbolEqualityComparer.Default.Equals(method.ContainingType, outcomeType)) { return false; }
+
+ for (int i = 0; i < method.TypeParameters.Length; i++) {
+ if (method.TypeParameters[i].Name == ExceptionTypeParameterName) {
+ caughtExceptionType = method.TypeArguments[i];
+
+ return caughtExceptionType is not null;
+ }
+ }
+
+ return false;
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers/PreferNonThrowingAlternativeToTryAnalyzer.cs b/FirstClassErrors.Analyzers/PreferNonThrowingAlternativeToTryAnalyzer.cs
new file mode 100644
index 00000000..10d184f5
--- /dev/null
+++ b/FirstClassErrors.Analyzers/PreferNonThrowingAlternativeToTryAnalyzer.cs
@@ -0,0 +1,53 @@
+using System.Collections.Immutable;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace FirstClassErrors.Analyzers;
+
+///
+/// FCE021 — reports a synchronous, value-producing Outcome.Try whose operation is a lambda that only calls a
+/// single throwing member which already has a non-throwing counterpart (a bool TryParse(..., out T) or
+/// TryCreate of matching shape) available for the target framework being compiled. There is nothing to catch:
+/// the caller should map the counterpart's false result to an error instead. On by default as a warning
+/// (advisory: suppress where the counterpart is not a true inverse of the wrapped call).
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class PreferNonThrowingAlternativeToTryAnalyzer : DiagnosticAnalyzer {
+
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(Descriptors.PreferNonThrowingAlternativeToTry);
+
+ ///
+ public override void Initialize(AnalysisContext context) {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.RegisterCompilationStartAction(OnCompilationStart);
+ }
+
+ private static void OnCompilationStart(CompilationStartAnalysisContext context) {
+ INamedTypeSymbol? outcomeType = context.Compilation.GetTypeByMetadataName(OutcomeTryFacts.OutcomeMetadataName);
+ if (outcomeType is null) { return; }
+
+ INamedTypeSymbol? funcOfTType = context.Compilation.GetTypeByMetadataName(TryAlternativeFacts.FuncOfTMetadataName);
+ if (funcOfTType is null) { return; }
+
+ context.RegisterOperationAction(operationContext => Analyze(operationContext, outcomeType, funcOfTType), OperationKind.Invocation);
+ }
+
+ private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol outcomeType, INamedTypeSymbol funcOfTType) {
+ IInvocationOperation invocation = (IInvocationOperation)context.Operation;
+
+ if (!OutcomeTryFacts.TryGetCaughtExceptionType(invocation, outcomeType, out _)) { return; }
+ if (!TryAlternativeFacts.TryGetPreferredAlternative(invocation, funcOfTType, out string throwingDisplay, out string alternativeDisplay)) { return; }
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ Descriptors.PreferNonThrowingAlternativeToTry,
+ invocation.Syntax.GetLocation(),
+ throwingDisplay,
+ alternativeDisplay));
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers/TryAlternativeFacts.cs b/FirstClassErrors.Analyzers/TryAlternativeFacts.cs
new file mode 100644
index 00000000..d41471dd
--- /dev/null
+++ b/FirstClassErrors.Analyzers/TryAlternativeFacts.cs
@@ -0,0 +1,195 @@
+using System.Collections.Immutable;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace FirstClassErrors.Analyzers;
+
+///
+/// Detection logic for FCE021. Recognizes a synchronous, value-producing Outcome.Try whose operation is a
+/// lambda that does nothing but call a single throwing member, and decides whether that member has a non-throwing
+/// counterpart (Try<Name> for a method, TryParse or TryCreate for a constructor) that
+/// actually resolves — with a compatible signature — in the compilation being analyzed.
+///
+///
+///
+/// The rule fires wherever a matching non-throwing counterpart resolves, regardless of where the wrapped type is
+/// declared. Structural matching cannot prove behavioural equivalence — a look-alike TryXxx may normalize
+/// its input, apply a different culture, have side effects, or be unrelated — so the diagnostic is advisory: it
+/// surfaces the candidate and leaves the judgement (and any SuppressMessage) to the developer. The
+/// signature check (same parameters, matching ref kinds, a trailing out of the result type, returning
+/// bool) at least guarantees the suggested call would compile.
+///
+///
+/// The signature check (same parameters, out result) : bool is what makes the rule both framework-aware
+/// and false-positive-resistant: the counterpart is looked up on the wrapped member's own type (and its base
+/// types) through the semantic model, so it is only found when the consumer's target framework exposes it, and
+/// only accepted when its shape is an exact drop-in for the throwing call.
+///
+///
+internal static class TryAlternativeFacts {
+
+ public const string FuncOfTMetadataName = "System.Func`1";
+
+ private const string OperationParameterName = "operation";
+ private const string TryPrefix = "Try";
+
+ private static readonly string[] ConstructorCounterpartNames = { "TryParse", "TryCreate" };
+
+ ///
+ /// Determines whether is the synchronous value-producing Outcome.Try
+ /// overload wrapping a single throwing framework member that has a resolvable non-throwing counterpart, and yields
+ /// display names for the message.
+ ///
+ public static bool TryGetPreferredAlternative(
+ IInvocationOperation tryInvocation,
+ INamedTypeSymbol funcOfTType,
+ out string throwingDisplay,
+ out string alternativeDisplay) {
+
+ throwingDisplay = string.Empty;
+ alternativeDisplay = string.Empty;
+
+ if (!TryGetSingleThrowingCall(tryInvocation, funcOfTType, out IMethodSymbol? throwingMember, out ITypeSymbol? resultType, out bool isConstructor)) {
+ return false;
+ }
+
+ INamedTypeSymbol containingType = throwingMember!.ContainingType;
+
+ string[] counterpartNames = isConstructor ? ConstructorCounterpartNames : new[] { TryPrefix + throwingMember.Name };
+
+ foreach (string counterpartName in counterpartNames) {
+ if (!HasCompatibleCounterpart(containingType, counterpartName, throwingMember, resultType!)) { continue; }
+
+ string typeName = containingType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
+ throwingDisplay = isConstructor ? $"new {typeName}" : $"{typeName}.{throwingMember.Name}";
+ alternativeDisplay = $"{typeName}.{counterpartName}";
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool TryGetSingleThrowingCall(
+ IInvocationOperation tryInvocation,
+ INamedTypeSymbol funcOfTType,
+ out IMethodSymbol? throwingMember,
+ out ITypeSymbol? resultType,
+ out bool isConstructor) {
+
+ throwingMember = null;
+ resultType = null;
+ isConstructor = false;
+
+ IArgumentOperation? operationArgument = FindArgument(tryInvocation, OperationParameterName);
+ if (operationArgument is null) { return false; }
+
+ // Only the synchronous value overload, whose operation is a Func. The async and void overloads
+ // (Func>, Action, Func) have no plain result to hand to
+ // an out parameter, so they are out of scope.
+ if (operationArgument.Parameter?.Type is not INamedTypeSymbol parameterType ||
+ !SymbolEqualityComparer.Default.Equals(parameterType.OriginalDefinition, funcOfTType)) {
+ return false;
+ }
+
+ if (operationArgument.Value is not IDelegateCreationOperation { Target: IAnonymousFunctionOperation lambda }) { return false; }
+ if (!TryGetSingleReturnedValue(lambda.Body, out IOperation? returnedValue)) { return false; }
+
+ switch (returnedValue) {
+ // Static only: an instance call's receiver cannot be carried into a static TryXxx, so the rewrite would
+ // not be one-for-one.
+ case IInvocationOperation { TargetMethod: { ReturnsVoid: false, IsStatic: true } method }:
+ throwingMember = method;
+ resultType = method.ReturnType;
+ isConstructor = false;
+
+ return true;
+
+ // An object/collection initializer cannot be carried into a TryXxx, so reject any construction that has one.
+ case IObjectCreationOperation { Initializer: null, Constructor: { } constructor, Type: INamedTypeSymbol createdType }:
+ throwingMember = constructor;
+ resultType = createdType;
+ isConstructor = true;
+
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ // A lambda body counts only when it is exactly one returned expression: `() => X.Parse(s)` or
+ // `() => { return X.Parse(s); }`. Anything else (extra statements, a cast around the call, no return) is left
+ // alone, so the suggested rewrite stays an exact one-for-one replacement.
+ private static bool TryGetSingleReturnedValue(IBlockOperation body, out IOperation? returnedValue) {
+ returnedValue = null;
+
+ if (body.Operations.Length != 1) { return false; }
+ if (body.Operations[0] is not IReturnOperation { ReturnedValue: { } value }) { return false; }
+
+ returnedValue = value;
+
+ return true;
+ }
+
+ private static bool HasCompatibleCounterpart(
+ INamedTypeSymbol containingType,
+ string counterpartName,
+ IMethodSymbol throwingMember,
+ ITypeSymbol resultType) {
+
+ for (INamedTypeSymbol? type = containingType; type is not null; type = type.BaseType) {
+ foreach (ISymbol member in type.GetMembers(counterpartName)) {
+ if (member is not IMethodSymbol candidate) { continue; }
+ if (!candidate.IsStatic || candidate.DeclaredAccessibility != Accessibility.Public) { continue; }
+ if (candidate.ReturnType.SpecialType != SpecialType.System_Boolean) { continue; }
+
+ IMethodSymbol constructed;
+ if (throwingMember.TypeArguments.Length > 0) {
+ if (candidate.TypeParameters.Length != throwingMember.TypeArguments.Length) { continue; }
+ constructed = candidate.Construct(throwingMember.TypeArguments.ToArray());
+ } else if (candidate.TypeParameters.Length != 0) {
+ continue;
+ } else {
+ constructed = candidate;
+ }
+
+ if (SignatureIsDropInReplacement(throwingMember.Parameters, constructed.Parameters, resultType)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ // The counterpart must take the throwing member's parameters unchanged — same types AND same ref kinds — then a
+ // single trailing `out result`, and nothing else.
+ private static bool SignatureIsDropInReplacement(
+ ImmutableArray throwingParameters,
+ ImmutableArray candidateParameters,
+ ITypeSymbol resultType) {
+
+ if (candidateParameters.Length != throwingParameters.Length + 1) { return false; }
+
+ for (int i = 0; i < throwingParameters.Length; i++) {
+ if (candidateParameters[i].RefKind == RefKind.Out) { return false; }
+ if (candidateParameters[i].RefKind != throwingParameters[i].RefKind) { return false; }
+ if (!SymbolEqualityComparer.Default.Equals(candidateParameters[i].Type, throwingParameters[i].Type)) { return false; }
+ }
+
+ IParameterSymbol trailing = candidateParameters[candidateParameters.Length - 1];
+
+ return trailing.RefKind == RefKind.Out && SymbolEqualityComparer.Default.Equals(trailing.Type, resultType);
+ }
+
+ private static IArgumentOperation? FindArgument(IInvocationOperation invocation, string parameterName) {
+ foreach (IArgumentOperation argument in invocation.Arguments) {
+ if (argument.Parameter?.Name == parameterName) { return argument; }
+ }
+
+ return null;
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers/TryCatchesCancellationAnalyzer.cs b/FirstClassErrors.Analyzers/TryCatchesCancellationAnalyzer.cs
new file mode 100644
index 00000000..390cabf9
--- /dev/null
+++ b/FirstClassErrors.Analyzers/TryCatchesCancellationAnalyzer.cs
@@ -0,0 +1,54 @@
+using System.Collections.Immutable;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace FirstClassErrors.Analyzers;
+
+///
+/// FCE022 — reports an Outcome.Try<..., TException> whose caught type is, or derives from,
+/// . Try guards its catch with
+/// when (exception is not OperationCanceledException), so binding TException to a cancellation type
+/// makes the catch unreachable: the filter is a contradiction, the mapper never runs, and no Outcome is
+/// produced. It is always a mistake and, unlike an unreachable ordinary catch, the compiler does not flag it.
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class TryCatchesCancellationAnalyzer : DiagnosticAnalyzer {
+
+ private const string OperationCanceledExceptionMetadataName = "System.OperationCanceledException";
+
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(Descriptors.TryCatchesCancellation);
+
+ ///
+ public override void Initialize(AnalysisContext context) {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.RegisterCompilationStartAction(OnCompilationStart);
+ }
+
+ private static void OnCompilationStart(CompilationStartAnalysisContext context) {
+ INamedTypeSymbol? outcomeType = context.Compilation.GetTypeByMetadataName(OutcomeTryFacts.OutcomeMetadataName);
+ if (outcomeType is null) { return; }
+
+ INamedTypeSymbol? cancellationType = context.Compilation.GetTypeByMetadataName(OperationCanceledExceptionMetadataName);
+ if (cancellationType is null) { return; }
+
+ context.RegisterOperationAction(operationContext => Analyze(operationContext, outcomeType, cancellationType), OperationKind.Invocation);
+ }
+
+ private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol outcomeType, INamedTypeSymbol cancellationType) {
+ IInvocationOperation invocation = (IInvocationOperation)context.Operation;
+
+ if (!OutcomeTryFacts.TryGetCaughtExceptionType(invocation, outcomeType, out ITypeSymbol? caughtType)) { return; }
+ if (!SymbolFacts.IsOrInheritsFrom(caughtType!, cancellationType)) { return; }
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ Descriptors.TryCatchesCancellation,
+ invocation.Syntax.GetLocation(),
+ caughtType!.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)));
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers/TryCatchesRichProtocolExceptionAnalyzer.cs b/FirstClassErrors.Analyzers/TryCatchesRichProtocolExceptionAnalyzer.cs
new file mode 100644
index 00000000..9ce36735
--- /dev/null
+++ b/FirstClassErrors.Analyzers/TryCatchesRichProtocolExceptionAnalyzer.cs
@@ -0,0 +1,73 @@
+using System.Collections.Immutable;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace FirstClassErrors.Analyzers;
+
+///
+/// FCE020 — reports an Outcome.Try<..., TException> whose caught type is (or derives from) a protocol
+/// failure such as HttpRequestException, WebException, SocketException or DbException.
+/// These failures carry status or protocol data beyond the exception, so a dedicated adapter that inspects the result
+/// keeps information Try would discard. Opt-in and disabled by default.
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class TryCatchesRichProtocolExceptionAnalyzer : DiagnosticAnalyzer {
+
+ private static readonly string[] ProtocolExceptionMetadataNames = {
+ "System.Net.Http.HttpRequestException",
+ "System.Net.WebException",
+ "System.Net.Sockets.SocketException",
+ "System.Data.Common.DbException",
+ };
+
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(Descriptors.TryCatchesRichProtocolException);
+
+ ///
+ public override void Initialize(AnalysisContext context) {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.RegisterCompilationStartAction(OnCompilationStart);
+ }
+
+ private static void OnCompilationStart(CompilationStartAnalysisContext context) {
+ INamedTypeSymbol? outcomeType = context.Compilation.GetTypeByMetadataName(OutcomeTryFacts.OutcomeMetadataName);
+ if (outcomeType is null) { return; }
+
+ ImmutableArray protocolTypes = ResolveProtocolTypes(context.Compilation);
+ if (protocolTypes.IsEmpty) { return; }
+
+ context.RegisterOperationAction(operationContext => Analyze(operationContext, outcomeType, protocolTypes), OperationKind.Invocation);
+ }
+
+ private static ImmutableArray ResolveProtocolTypes(Compilation compilation) {
+ ImmutableArray.Builder builder = ImmutableArray.CreateBuilder();
+ foreach (string metadataName in ProtocolExceptionMetadataNames) {
+ INamedTypeSymbol? type = compilation.GetTypeByMetadataName(metadataName);
+ if (type is not null) { builder.Add(type); }
+ }
+
+ return builder.ToImmutable();
+ }
+
+ private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol outcomeType, ImmutableArray protocolTypes) {
+ IInvocationOperation invocation = (IInvocationOperation)context.Operation;
+
+ if (!OutcomeTryFacts.TryGetCaughtExceptionType(invocation, outcomeType, out ITypeSymbol? caughtType)) { return; }
+
+ foreach (INamedTypeSymbol protocolType in protocolTypes) {
+ if (!SymbolFacts.IsOrInheritsFrom(caughtType!, protocolType)) { continue; }
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ Descriptors.TryCatchesRichProtocolException,
+ invocation.Syntax.GetLocation(),
+ caughtType!.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)));
+
+ return;
+ }
+ }
+
+}
diff --git a/FirstClassErrors.Analyzers/TryCatchesTooBroadlyAnalyzer.cs b/FirstClassErrors.Analyzers/TryCatchesTooBroadlyAnalyzer.cs
new file mode 100644
index 00000000..dadf45b9
--- /dev/null
+++ b/FirstClassErrors.Analyzers/TryCatchesTooBroadlyAnalyzer.cs
@@ -0,0 +1,52 @@
+using System.Collections.Immutable;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace FirstClassErrors.Analyzers;
+
+///
+/// FCE019 — reports an Outcome.Try<..., TException> whose caught type is .
+/// Try is meant to catch the single exception that denotes an anticipated failure; catching the near-root type
+/// also swallows unexpected bugs and turns them into anticipated errors, defeating the purpose of Outcome.
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class TryCatchesTooBroadlyAnalyzer : DiagnosticAnalyzer {
+
+ private const string ExceptionMetadataName = "System.Exception";
+
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(Descriptors.TryCatchesTooBroadly);
+
+ ///
+ public override void Initialize(AnalysisContext context) {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.RegisterCompilationStartAction(OnCompilationStart);
+ }
+
+ private static void OnCompilationStart(CompilationStartAnalysisContext context) {
+ INamedTypeSymbol? outcomeType = context.Compilation.GetTypeByMetadataName(OutcomeTryFacts.OutcomeMetadataName);
+ if (outcomeType is null) { return; }
+
+ INamedTypeSymbol? exceptionType = context.Compilation.GetTypeByMetadataName(ExceptionMetadataName);
+ if (exceptionType is null) { return; }
+
+ context.RegisterOperationAction(operationContext => Analyze(operationContext, outcomeType, exceptionType), OperationKind.Invocation);
+ }
+
+ private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol outcomeType, INamedTypeSymbol exceptionType) {
+ IInvocationOperation invocation = (IInvocationOperation)context.Operation;
+
+ if (!OutcomeTryFacts.TryGetCaughtExceptionType(invocation, outcomeType, out ITypeSymbol? caughtType)) { return; }
+ if (!SymbolEqualityComparer.Default.Equals(caughtType, exceptionType)) { return; }
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ Descriptors.TryCatchesTooBroadly,
+ invocation.Syntax.GetLocation(),
+ caughtType!.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)));
+ }
+
+}
diff --git a/FirstClassErrors.UnitTests/OutcomeTryTests.cs b/FirstClassErrors.UnitTests/OutcomeTryTests.cs
new file mode 100644
index 00000000..f8b65cd4
--- /dev/null
+++ b/FirstClassErrors.UnitTests/OutcomeTryTests.cs
@@ -0,0 +1,598 @@
+#region Usings declarations
+
+using FirstClassErrors.Testing;
+
+using JetBrains.Annotations;
+
+using NFluent;
+
+#endregion
+
+namespace FirstClassErrors.UnitTests;
+
+[TestSubject(typeof(Outcome))]
+public sealed class OutcomeTryTests {
+
+ #region Synchronous value-producing Try
+
+ [Fact(DisplayName = "Try returns a success carrying the result when the operation does not throw.")]
+ public void TryReturnsASuccessCarryingTheResultWhenTheOperationDoesNotThrow() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise
+ Outcome outcome = Outcome.Try(() => 42, _ => error);
+
+ // Verify
+ Check.That(outcome.IsSuccess).IsTrue();
+ Check.That(outcome.GetResultOrThrow()).IsEqualTo(42);
+ }
+
+ [Fact(DisplayName = "Try maps the caught exception to a failure carrying the mapper's error.")]
+ public void TryMapsTheCaughtExceptionToAFailureCarryingTheMappersError() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise
+ Outcome outcome = Outcome.Try((Func)(() => throw new InvalidOperationException("boom")), _ => error);
+
+ // Verify
+ Check.That(outcome.IsFailure).IsTrue();
+ Check.That(outcome.Error).IsSameReferenceAs(error);
+ }
+
+ [Fact(DisplayName = "Try passes the thrown exception instance to the mapper.")]
+ public void TryPassesTheThrownExceptionInstanceToTheMapper() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+ InvalidOperationException thrown = new("boom");
+ Exception? received = null;
+
+ // Exercise
+ Outcome.Try((Func)(() => throw thrown), exception => {
+ received = exception;
+
+ return error;
+ });
+
+ // Verify
+ Check.That(received).IsSameReferenceAs(thrown);
+ }
+
+ [Fact(DisplayName = "Try does not catch an exception of a different type.")]
+ public void TryDoesNotCatchAnExceptionOfADifferentType() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise & verify
+ Check.ThatCode(() => Outcome.Try((Func)(() => throw new InvalidOperationException("boom")), _ => error))
+ .Throws()
+ .WithMessage("boom");
+ }
+
+ [Fact(DisplayName = "Try lets an OperationCanceledException propagate even when TException is Exception.")]
+ public void TryLetsAnOperationCanceledExceptionPropagateEvenWhenTExceptionIsException() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise & verify
+ Check.ThatCode(() => Outcome.Try((Func)(() => throw new OperationCanceledException()), _ => error))
+ .Throws();
+ }
+
+ [Fact(DisplayName = "Try guards against a null operation.")]
+ public void TryGuardsAgainstANullOperation() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise & verify
+ Check.ThatCode(() => Outcome.Try((Func)null!, _ => error))
+ .Throws();
+ }
+
+ [Fact(DisplayName = "Try guards against a null onError mapper.")]
+ public void TryGuardsAgainstANullOnErrorMapper() {
+ // Exercise & verify
+ Check.ThatCode(() => Outcome.Try(() => 42, (Func)null!))
+ .Throws();
+ }
+
+ [Fact(DisplayName = "Try throws an ArgumentNullException when the mapper maps a caught exception to a null error.")]
+ public void TryThrowsAnArgumentNullExceptionWhenTheMapperReturnsANullError() {
+ // Exercise & verify
+ Check.ThatCode(() => Outcome.Try((Func)(() => throw new InvalidOperationException()), _ => (Error)null!))
+ .Throws();
+ }
+
+ [Fact(DisplayName = "Try surfaces a null operation result as a contract violation rather than mapping it, even when TException is broad.")]
+ public void TrySurfacesANullOperationResultRatherThanMappingIt() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise & verify: Success rejects a null result, and because it runs after the catch that
+ // ArgumentNullException surfaces as the contract violation it is rather than being mapped through onError.
+ Check.ThatCode(() => Outcome.Try((Func)(() => null!), _ => error))
+ .Throws();
+ }
+
+ #endregion
+
+ #region Synchronous side-effecting Try
+
+ [Fact(DisplayName = "Try (void) returns a success and runs the side effect when the operation does not throw.")]
+ public void TryVoidReturnsASuccessAndRunsTheSideEffectWhenTheOperationDoesNotThrow() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+ bool ran = false;
+
+ // Exercise
+ Outcome outcome = Outcome.Try(() => { ran = true; }, _ => error);
+
+ // Verify
+ Check.That(ran).IsTrue();
+ Check.That(outcome.IsSuccess).IsTrue();
+ }
+
+ [Fact(DisplayName = "Try (void) maps the caught exception to a failure.")]
+ public void TryVoidMapsTheCaughtExceptionToAFailure() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise
+ Outcome outcome = Outcome.Try((Action)(() => throw new InvalidOperationException()), _ => error);
+
+ // Verify
+ Check.That(outcome.IsFailure).IsTrue();
+ Check.That(outcome.Error).IsSameReferenceAs(error);
+ }
+
+ [Fact(DisplayName = "Try (void) does not catch an exception of a different type.")]
+ public void TryVoidDoesNotCatchAnExceptionOfADifferentType() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise & verify
+ Check.ThatCode(() => Outcome.Try((Action)(() => throw new InvalidOperationException("boom")), _ => error))
+ .Throws()
+ .WithMessage("boom");
+ }
+
+ [Fact(DisplayName = "Try (void) lets an OperationCanceledException propagate.")]
+ public void TryVoidLetsAnOperationCanceledExceptionPropagate() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise & verify
+ Check.ThatCode(() => Outcome.Try((Action)(() => throw new OperationCanceledException()), _ => error))
+ .Throws();
+ }
+
+ [Fact(DisplayName = "Try (void) guards against a null operation.")]
+ public void TryVoidGuardsAgainstANullOperation() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise & verify
+ Check.ThatCode(() => Outcome.Try((Action)null!, _ => error))
+ .Throws();
+ }
+
+ [Fact(DisplayName = "Try (void) guards against a null onError mapper.")]
+ public void TryVoidGuardsAgainstANullOnErrorMapper() {
+ // Exercise & verify
+ Check.ThatCode(() => Outcome.Try(() => { }, (Func)null!))
+ .Throws();
+ }
+
+ #endregion
+
+ #region Asynchronous value-producing Try
+
+ [Fact(DisplayName = "Awaiting the async Try returns a success carrying the result when the operation does not throw.")]
+ public async Task AwaitingTheAsyncTryReturnsASuccessCarryingTheResultWhenTheOperationDoesNotThrow() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise
+ Outcome outcome = await Outcome.Try(
+ _ => Task.FromResult(42), _ => error, TestContext.Current.CancellationToken);
+
+ // Verify
+ Check.That(outcome.IsSuccess).IsTrue();
+ Check.That(outcome.GetResultOrThrow()).IsEqualTo(42);
+ }
+
+ [Fact(DisplayName = "Awaiting the async Try maps a caught exception to a failure.")]
+ public async Task AwaitingTheAsyncTryMapsACaughtExceptionToAFailure() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise
+ Outcome outcome = await Outcome.Try(
+ async _ => {
+ await Task.Yield();
+
+ throw new InvalidOperationException("boom");
+ },
+ _ => error,
+ TestContext.Current.CancellationToken);
+
+ // Verify
+ Check.That(outcome.IsFailure).IsTrue();
+ Check.That(outcome.Error).IsSameReferenceAs(error);
+ }
+
+ [Fact(DisplayName = "Awaiting the async Try does not catch an exception of a different type.")]
+ public async Task AwaitingTheAsyncTryDoesNotCatchAnExceptionOfADifferentType() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise & verify
+ await Assert.ThrowsAsync(
+ () => Outcome.Try(
+ async _ => {
+ await Task.Yield();
+
+ throw new InvalidOperationException("boom");
+ },
+ _ => error,
+ TestContext.Current.CancellationToken));
+ }
+
+ [Fact(DisplayName = "Awaiting the async Try lets a cancellation propagate rather than mapping it to a failure.")]
+ public async Task AwaitingTheAsyncTryLetsACancellationPropagateRatherThanMappingItToAFailure() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ using CancellationTokenSource cts = new();
+ cts.Cancel();
+
+ // Exercise & verify
+ await Assert.ThrowsAsync(
+ () => Outcome.Try(
+ ct => throw new OperationCanceledException(ct),
+ _ => error,
+ cts.Token));
+ }
+
+ [Fact(DisplayName = "Awaiting the async Try passes the cancellation token to the operation.")]
+ public async Task AwaitingTheAsyncTryPassesTheCancellationTokenToTheOperation() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ using CancellationTokenSource cts = new();
+ CancellationToken received = default;
+
+ // Exercise
+ await Outcome.Try(
+ ct => {
+ received = ct;
+
+ return Task.FromResult(0);
+ },
+ _ => error,
+ cts.Token);
+
+ // Verify
+ Check.That(received).IsEqualTo(cts.Token);
+ }
+
+ [Fact(DisplayName = "Awaiting the async Try surfaces a null task returned by the operation.")]
+ public async Task AwaitingTheAsyncTrySurfacesANullTaskReturnedByTheOperation() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise & verify
+ await Assert.ThrowsAsync(
+ () => Outcome.Try((Func>)(_ => null!), _ => error, TestContext.Current.CancellationToken));
+ }
+
+ [Fact(DisplayName = "Awaiting the async Try surfaces a null task even when TException is broad, rather than mapping it.")]
+ public async Task AwaitingTheAsyncTrySurfacesANullTaskEvenWhenTExceptionIsBroad() {
+ // A null task is a contract violation, not an anticipated failure: the guard sits outside the mapping region,
+ // so it surfaces as an InvalidOperationException even under a broad Exception catch, mirroring the null-result
+ // contract. A specific TException surfaces it too (see the test above).
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise & verify
+ await Assert.ThrowsAsync(
+ () => Outcome.Try(
+ (Func>)(_ => null!), _ => error, TestContext.Current.CancellationToken));
+ }
+
+ [Fact(DisplayName = "Awaiting the async Try surfaces a null operation result as a contract violation rather than mapping it, even when TException is broad.")]
+ public async Task AwaitingTheAsyncTrySurfacesANullOperationResultRatherThanMappingIt() {
+ // Distinct from the null-task case above: here the operation returns a non-null task that resolves to a null
+ // value. Success rejects it after the catch, so the ArgumentNullException surfaces even though TException is
+ // broad enough to have mapped it.
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise & verify
+ await Assert.ThrowsAsync(
+ () => Outcome.Try(_ => Task.FromResult(null!), _ => error, TestContext.Current.CancellationToken));
+ }
+
+ [Fact(DisplayName = "Awaiting the async Try guards against a null operation.")]
+ public async Task AwaitingTheAsyncTryGuardsAgainstANullOperation() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise & verify
+ await Assert.ThrowsAsync(
+ () => Outcome.Try((Func>)null!, _ => error, TestContext.Current.CancellationToken));
+ }
+
+ [Fact(DisplayName = "Awaiting the async Try guards against a null onError mapper.")]
+ public async Task AwaitingTheAsyncTryGuardsAgainstANullOnErrorMapper() {
+ // Exercise & verify
+ await Assert.ThrowsAsync(
+ () => Outcome.Try(_ => Task.FromResult(0), (Func)null!, TestContext.Current.CancellationToken));
+ }
+
+ #endregion
+
+ #region Asynchronous side-effecting Try
+
+ [Fact(DisplayName = "Awaiting the async Try (void) returns a success and runs the side effect when the operation does not throw.")]
+ public async Task AwaitingTheAsyncTryVoidReturnsASuccessAndRunsTheSideEffectWhenTheOperationDoesNotThrow() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+ bool ran = false;
+
+ // Exercise
+ Outcome outcome = await Outcome.Try(
+ async _ => {
+ await Task.Yield();
+
+ ran = true;
+ },
+ _ => error,
+ TestContext.Current.CancellationToken);
+
+ // Verify
+ Check.That(ran).IsTrue();
+ Check.That(outcome.IsSuccess).IsTrue();
+ }
+
+ [Fact(DisplayName = "Awaiting the async Try (void) maps a caught exception to a failure.")]
+ public async Task AwaitingTheAsyncTryVoidMapsACaughtExceptionToAFailure() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise
+ Outcome outcome = await Outcome.Try(
+ async _ => {
+ await Task.Yield();
+
+ throw new InvalidOperationException();
+ },
+ _ => error,
+ TestContext.Current.CancellationToken);
+
+ // Verify
+ Check.That(outcome.IsFailure).IsTrue();
+ Check.That(outcome.Error).IsSameReferenceAs(error);
+ }
+
+ [Fact(DisplayName = "Awaiting the async Try (void) lets a cancellation propagate.")]
+ public async Task AwaitingTheAsyncTryVoidLetsACancellationPropagate() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ using CancellationTokenSource cts = new();
+ cts.Cancel();
+
+ // Exercise & verify
+ await Assert.ThrowsAsync(
+ () => Outcome.Try(
+ ct => throw new OperationCanceledException(ct),
+ _ => error,
+ cts.Token));
+ }
+
+ [Fact(DisplayName = "Awaiting the async Try (void) guards against a null operation.")]
+ public async Task AwaitingTheAsyncTryVoidGuardsAgainstANullOperation() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise & verify
+ await Assert.ThrowsAsync(
+ () => Outcome.Try((Func)null!, _ => error, TestContext.Current.CancellationToken));
+ }
+
+ [Fact(DisplayName = "Awaiting the async Try (void) does not catch an exception of a different type.")]
+ public async Task AwaitingTheAsyncTryVoidDoesNotCatchAnExceptionOfADifferentType() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise & verify
+ await Assert.ThrowsAsync(
+ () => Outcome.Try(
+ async _ => {
+ await Task.Yield();
+
+ throw new InvalidOperationException("boom");
+ },
+ _ => error,
+ TestContext.Current.CancellationToken));
+ }
+
+ [Fact(DisplayName = "Awaiting the async Try (void) guards against a null onError mapper.")]
+ public async Task AwaitingTheAsyncTryVoidGuardsAgainstANullOnErrorMapper() {
+ // Exercise & verify
+ await Assert.ThrowsAsync(
+ () => Outcome.Try(
+ (CancellationToken _) => Task.CompletedTask, (Func)null!, TestContext.Current.CancellationToken));
+ }
+
+ #endregion
+
+ #region Asynchronous value-producing Try (no cancellation token)
+
+ [Fact(DisplayName = "Awaiting the token-less async Try returns a success carrying the result when the operation does not throw.")]
+ public async Task AwaitingTheTokenlessAsyncTryReturnsASuccessCarryingTheResult() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise: an async lambda with no token binds to the Func> overload.
+ Outcome outcome = await Outcome.Try(
+ async () => {
+ await Task.Yield();
+
+ return 42;
+ },
+ _ => error);
+
+ // Verify
+ Check.That(outcome.IsSuccess).IsTrue();
+ Check.That(outcome.GetResultOrThrow()).IsEqualTo(42);
+ }
+
+ [Fact(DisplayName = "Awaiting the token-less async Try maps a throwing async lambda to a failure.")]
+ public async Task AwaitingTheTokenlessAsyncTryMapsAThrowingAsyncLambda() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise
+ Outcome outcome = await Outcome.Try(
+ async () => {
+ await Task.Yield();
+
+ throw new InvalidOperationException("boom");
+ },
+ _ => error);
+
+ // Verify
+ Check.That(outcome.IsFailure).IsTrue();
+ Check.That(outcome.Error).IsSameReferenceAs(error);
+ }
+
+ [Fact(DisplayName = "Awaiting the token-less async Try surfaces a null operation result rather than mapping it.")]
+ public async Task AwaitingTheTokenlessAsyncTrySurfacesANullOperationResult() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise & verify
+ await Assert.ThrowsAsync(
+ () => Outcome.Try(() => Task.FromResult(null!), _ => error));
+ }
+
+ [Fact(DisplayName = "Awaiting the token-less async Try guards against a null operation.")]
+ public async Task AwaitingTheTokenlessAsyncTryGuardsAgainstANullOperation() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise & verify
+ await Assert.ThrowsAsync(
+ () => Outcome.Try((Func>)null!, _ => error));
+ }
+
+ [Fact(DisplayName = "Awaiting the token-less async Try guards against a null onError mapper.")]
+ public async Task AwaitingTheTokenlessAsyncTryGuardsAgainstANullOnErrorMapper() {
+ // Exercise & verify
+ await Assert.ThrowsAsync(
+ () => Outcome.Try(() => Task.FromResult(0), (Func)null!));
+ }
+
+ #endregion
+
+ #region Asynchronous side-effecting Try (no cancellation token)
+
+ [Fact(DisplayName = "Awaiting the token-less async Try (void) returns a success and runs the side effect when the operation does not throw.")]
+ public async Task AwaitingTheTokenlessAsyncTryVoidReturnsASuccessAndRunsTheSideEffect() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+ bool ran = false;
+
+ // Exercise
+ Outcome outcome = await Outcome.Try(
+ async () => {
+ await Task.Yield();
+
+ ran = true;
+ },
+ _ => error);
+
+ // Verify
+ Check.That(ran).IsTrue();
+ Check.That(outcome.IsSuccess).IsTrue();
+ }
+
+ [Fact(DisplayName = "Awaiting the token-less async Try (void) maps a throwing async lambda instead of letting it escape as async void.")]
+ public async Task AwaitingTheTokenlessAsyncTryVoidMapsAThrowingAsyncLambda() {
+ // Regression guard for the async-void footgun: a parameterless async lambda now binds to the Func
+ // overload, so its post-await exception is awaited and mapped rather than raised out-of-band.
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise
+ Outcome outcome = await Outcome.Try(
+ async () => {
+ await Task.Yield();
+
+ throw new InvalidOperationException("boom");
+ },
+ _ => error);
+
+ // Verify
+ Check.That(outcome.IsFailure).IsTrue();
+ Check.That(outcome.Error).IsSameReferenceAs(error);
+ }
+
+ [Fact(DisplayName = "Awaiting the token-less async Try (void) maps a fire-and-forget task that faults instead of dropping it.")]
+ public async Task AwaitingTheTokenlessAsyncTryVoidMapsAFireAndForgetTask() {
+ // Regression guard for the non-async fire-and-forget case: () => ReturnsTask() binds to the Func overload
+ // and is awaited, so a faulting task is mapped rather than silently dropped as it would be on the Action overload.
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise
+ Outcome outcome = await Outcome.Try(() => FaultingTaskAsync(), _ => error);
+
+ // Verify
+ Check.That(outcome.IsFailure).IsTrue();
+ Check.That(outcome.Error).IsSameReferenceAs(error);
+ }
+
+ [Fact(DisplayName = "Awaiting the token-less async Try (void) does not catch an exception of a different type.")]
+ public async Task AwaitingTheTokenlessAsyncTryVoidDoesNotCatchADifferentType() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise & verify
+ await Assert.ThrowsAsync(
+ () => Outcome.Try(
+ async () => {
+ await Task.Yield();
+
+ throw new InvalidOperationException("boom");
+ },
+ _ => error));
+ }
+
+ [Fact(DisplayName = "Awaiting the token-less async Try (void) guards against a null operation.")]
+ public async Task AwaitingTheTokenlessAsyncTryVoidGuardsAgainstANullOperation() {
+ // Setup
+ DomainError error = ErrorFactory.Domain(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any());
+
+ // Exercise & verify
+ await Assert.ThrowsAsync(
+ () => Outcome.Try((Func)null!, _ => error));
+ }
+
+ [Fact(DisplayName = "Awaiting the token-less async Try (void) guards against a null onError mapper.")]
+ public async Task AwaitingTheTokenlessAsyncTryVoidGuardsAgainstANullOnErrorMapper() {
+ // Exercise & verify
+ await Assert.ThrowsAsync(
+ () => Outcome.Try(() => Task.CompletedTask, (Func)null!));
+ }
+
+ private static Task FaultingTaskAsync() {
+ return Task.FromException(new InvalidOperationException("boom"));
+ }
+
+ #endregion
+
+}
diff --git a/FirstClassErrors/Outcome.cs b/FirstClassErrors/Outcome.cs
index 11a68ff8..da22dea2 100644
--- a/FirstClassErrors/Outcome.cs
+++ b/FirstClassErrors/Outcome.cs
@@ -41,6 +41,315 @@ public static Outcome Failure(Error error) {
return new Outcome(error);
}
+ ///
+ /// Runs a value-producing operation that may throw, catching a single exception type and turning it into a failed
+ /// through the supplied mapper.
+ ///
+ /// The type of the value produced on success.
+ /// The single exception type to catch and map; any other exception propagates.
+ /// The operation to run.
+ ///
+ /// Maps a caught to the that describes the failure. It is
+ /// mandatory: the library never converts an exception to an error automatically, because an automatic conversion
+ /// would yield errors without a stable — the very thing the FCE005 analyzer discourages.
+ /// This mapper is the natural place to extract only what is safe from the exception rather than leaking its raw
+ /// message (see the FCE017/FCE018 analyzers on sensitive and oversized context data).
+ ///
+ ///
+ /// A successful carrying the operation's result, or a failed one carrying the mapped
+ /// error when threw a .
+ ///
+ ///
+ /// Thrown if or is null; if
+ /// returns a null result (which rejects as
+ /// a contract violation rather than mapping it); or if a caught exception is mapped by
+ /// to a null error (the mapped value flows through , which rejects
+ /// null).
+ ///
+ ///
+ ///
+ /// Try brings throwing code into the outcome flow — the inverse of
+ /// and , which leave it. It is a
+ /// narrow tool: when the operation already has a non-throwing counterpart (a bool TryParse(..., out T)
+ /// or TryCreate), prefer that and map its false result — there is no exception to catch. Reach
+ /// for Try only for a throwing primitive that has no such variant available, a case a
+ /// .NET Standard 2.0 consumer meets more often than a modern one. The FCE019 and FCE020 analyzers flag the
+ /// common misuses.
+ ///
+ ///
+ /// Only is caught. Any other exception propagates unchanged: an
+ /// models an anticipated failure, not an unexpected runtime crash, so an
+ /// exception you did not name is never silently turned into a value.
+ ///
+ ///
+ /// always propagates, even when it is assignable to
+ /// (for instance when is
+ /// ): a cancellation is not a failure to capture as an error. Binding
+ /// to (or its
+ /// TaskCanceledException subtype) is therefore pointless — the catch is unreachable and
+ /// never runs; do not model cancellation as a Try failure.
+ ///
+ ///
+ /// Try is intended for operations where the thrown exception is the whole failure signal. It is
+ /// deliberately not meant for protocols whose failures are carried by a status or result code rather than
+ /// by an exception (HTTP responses, database provider codes); those deserve a dedicated adapter that inspects
+ /// the result instead of catching a throw.
+ ///
+ ///
+ public static Outcome Try(Func operation, Func onError)
+ where T : notnull
+ where TException : Exception {
+ if (operation is null) { throw new ArgumentNullException(nameof(operation)); }
+ if (onError is null) { throw new ArgumentNullException(nameof(onError)); }
+
+ T result;
+ try {
+ result = operation();
+ } catch (TException exception) when (exception is not OperationCanceledException) {
+ return Outcome.Failure(onError(exception));
+ }
+
+ // Success runs after the catch on purpose: it rejects a null result, and a null result is a contract
+ // violation of the operation, not an anticipated failure. Calling it here lets that ArgumentNullException
+ // surface instead of being mapped by onError when TException is broad enough to catch it.
+ return Outcome.Success(result);
+ }
+
+ ///
+ /// Runs a side-effecting operation that may throw, catching a single exception type and turning it into a failed
+ /// through the supplied mapper.
+ ///
+ /// The single exception type to catch and map; any other exception propagates.
+ /// The operation to run.
+ ///
+ /// Maps a caught to the that describes the failure. See the
+ /// value-returning overload for the doctrine on why this mapper is mandatory.
+ ///
+ ///
+ /// when completed, or a failed carrying
+ /// the mapped error when it threw a .
+ ///
+ ///
+ /// Thrown if or is null, or if a caught exception
+ /// is mapped by to a null error.
+ ///
+ ///
+ /// Behaves like the value-returning overload: only is caught, and
+ /// always propagates rather than being captured as an error.
+ ///
+ public static Outcome Try(Action operation, Func onError)
+ where TException : Exception {
+ if (operation is null) { throw new ArgumentNullException(nameof(operation)); }
+ if (onError is null) { throw new ArgumentNullException(nameof(onError)); }
+
+ try {
+ operation();
+
+ return Success;
+ } catch (TException exception) when (exception is not OperationCanceledException) {
+ return Failure(onError(exception));
+ }
+ }
+
+ ///
+ /// Runs an asynchronous value-producing operation that may throw, catching a single exception type and turning it
+ /// into a failed through the supplied mapper.
+ ///
+ /// The type of the value produced on success.
+ /// The single exception type to catch and map; any other exception propagates.
+ /// The asynchronous operation to run.
+ ///
+ /// Maps a caught to the that describes the failure. See the
+ /// synchronous value-returning overload for the doctrine on why this mapper is mandatory.
+ ///
+ /// A token to observe for cancellation requests.
+ ///
+ /// A resolving to a successful carrying the operation's
+ /// result, or to a failed one carrying the mapped error when threw a
+ /// .
+ ///
+ ///
+ /// Thrown if or is null; if
+ /// resolves to a null result (which rejects
+ /// as a contract violation rather than mapping it); or if a caught exception is mapped by
+ /// to a null error.
+ ///
+ ///
+ /// Thrown if returns a null task — a contract violation that surfaces rather
+ /// than being mapped, even when is broad enough to catch it.
+ ///
+ ///
+ /// Behaves like the synchronous value-returning overload: only is caught, and
+ /// always propagates. A cancellation raised through
+ /// therefore surfaces to the awaiter rather than becoming a failed outcome.
+ /// The operation signals failure through the returned ; a null task, like a
+ /// null result, is a contract violation and always surfaces rather than being mapped.
+ ///
+ public static async Task> Try(Func> operation,
+ Func onError,
+ CancellationToken cancellationToken = default)
+ where T : notnull
+ where TException : Exception {
+ if (operation is null) { throw new ArgumentNullException(nameof(operation)); }
+ if (onError is null) { throw new ArgumentNullException(nameof(onError)); }
+
+ Task task;
+ try {
+ task = operation(cancellationToken);
+ } catch (TException exception) when (exception is not OperationCanceledException) {
+ return Outcome.Failure(onError(exception));
+ }
+
+ // A null task and a null result are both contract violations of the operation, not anticipated failures, so
+ // both surface rather than being mapped: the null-task guard and Success both sit outside the region that maps
+ // the operation's exceptions. Invoking the operation stays inside the try, so a synchronous throw is still caught.
+ Task awaited = AsyncCallbackGuard.EnsureTask(task);
+
+ T result;
+ try {
+ result = await awaited.ConfigureAwait(false);
+ } catch (TException exception) when (exception is not OperationCanceledException) {
+ return Outcome