diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..01e314d
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,196 @@
+[*.{cs,vb}]
+#### Naming styles ####
+
+# Naming rules
+
+dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
+dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
+dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
+
+dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
+dotnet_naming_rule.types_should_be_pascal_case.symbols = types
+dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
+
+dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
+dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
+dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
+
+# Symbol specifications
+
+dotnet_naming_symbols.interface.applicable_kinds = interface
+dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.interface.required_modifiers =
+
+dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
+dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.types.required_modifiers =
+
+dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
+dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.non_field_members.required_modifiers =
+
+# Naming styles
+
+dotnet_naming_style.begins_with_i.required_prefix = I
+dotnet_naming_style.begins_with_i.required_suffix =
+dotnet_naming_style.begins_with_i.word_separator =
+dotnet_naming_style.begins_with_i.capitalization = pascal_case
+
+dotnet_naming_style.pascal_case.required_prefix =
+dotnet_naming_style.pascal_case.required_suffix =
+dotnet_naming_style.pascal_case.word_separator =
+dotnet_naming_style.pascal_case.capitalization = pascal_case
+
+dotnet_naming_style.pascal_case.required_prefix =
+dotnet_naming_style.pascal_case.required_suffix =
+dotnet_naming_style.pascal_case.word_separator =
+dotnet_naming_style.pascal_case.capitalization = pascal_case
+dotnet_style_operator_placement_when_wrapping = beginning_of_line
+tab_width = 4
+indent_size = 4
+end_of_line = crlf
+dotnet_style_coalesce_expression = true:suggestion
+dotnet_style_null_propagation = true:suggestion
+dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
+dotnet_style_prefer_auto_properties = true:silent
+dotnet_style_object_initializer = true:suggestion
+dotnet_style_collection_initializer = true:suggestion
+dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
+dotnet_style_prefer_conditional_expression_over_assignment = true:silent
+dotnet_style_prefer_conditional_expression_over_return = true:silent
+dotnet_style_explicit_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
+dotnet_style_prefer_compound_assignment = true:suggestion
+dotnet_style_prefer_simplified_interpolation = true:suggestion
+dotnet_style_namespace_match_folder = true:suggestion
+dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
+dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
+dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
+dotnet_style_readonly_field = true:suggestion
+dotnet_style_predefined_type_for_locals_parameters_members = true:silent
+dotnet_style_predefined_type_for_member_access = true:silent
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
+dotnet_style_allow_multiple_blank_lines_experimental = true:silent
+dotnet_style_allow_statement_immediately_after_block_experimental = true:silent
+dotnet_code_quality_unused_parameters = all:suggestion
+dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
+dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
+dotnet_style_qualification_for_field = false:silent
+dotnet_style_qualification_for_property = false:silent
+dotnet_style_qualification_for_method = false:silent
+dotnet_style_qualification_for_event = false:silent
+
+[*.cs]
+csharp_indent_labels = one_less_than_current
+csharp_using_directive_placement = outside_namespace:silent
+csharp_prefer_simple_using_statement = true:suggestion
+csharp_prefer_braces = true:silent
+csharp_style_namespace_declarations = file_scoped:suggestion
+csharp_style_prefer_method_group_conversion = true:silent
+csharp_style_prefer_top_level_statements = true:silent
+csharp_style_prefer_primary_constructors = true:suggestion
+csharp_prefer_system_threading_lock = true:suggestion
+csharp_style_expression_bodied_methods = true:warning
+csharp_style_expression_bodied_constructors = true:warning
+csharp_style_expression_bodied_operators = true:warning
+csharp_style_expression_bodied_properties = true:warning
+csharp_style_expression_bodied_indexers = true:warning
+csharp_style_expression_bodied_accessors = true:warning
+csharp_style_expression_bodied_lambdas = true:warning
+csharp_style_expression_bodied_local_functions = true:warning
+csharp_space_around_binary_operators = before_and_after
+csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion
+csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent
+csharp_style_conditional_delegate_call = true:suggestion
+csharp_style_throw_expression = true:suggestion
+csharp_style_prefer_null_check_over_type_check = true:suggestion
+csharp_prefer_simple_default_expression = true:suggestion
+csharp_style_prefer_local_over_anonymous_function = true:suggestion
+csharp_style_prefer_index_operator = true:suggestion
+csharp_style_prefer_range_operator = true:suggestion
+csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
+csharp_style_prefer_tuple_swap = true:suggestion
+csharp_style_prefer_utf8_string_literals = true:suggestion
+csharp_style_inlined_variable_declaration = true:suggestion
+csharp_style_deconstructed_variable_declaration = true:suggestion
+csharp_style_unused_value_assignment_preference = discard_variable:suggestion
+csharp_style_unused_value_expression_statement_preference = discard_variable:silent
+csharp_prefer_static_local_function = true:suggestion
+csharp_prefer_static_anonymous_function = true:suggestion
+csharp_style_prefer_readonly_struct = true:suggestion
+csharp_style_prefer_readonly_struct_member = true:suggestion
+csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent
+csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent
+csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent
+csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent
+csharp_style_prefer_switch_expression = true:suggestion
+csharp_style_prefer_pattern_matching = true:silent
+csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
+csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+csharp_style_prefer_not_pattern = true:suggestion
+csharp_style_prefer_extended_property_pattern = true:suggestion
+csharp_style_var_for_built_in_types = false:silent
+csharp_style_var_when_type_is_apparent = false:silent
+csharp_style_var_elsewhere = false:silent
+dotnet_diagnostic.SA1600.severity = none
+dotnet_diagnostic.SA1633.severity = none
+dotnet_diagnostic.SA1200.severity = none
+dotnet_diagnostic.IDE0005.severity = warning
+dotnet_diagnostic.SA1313.severity = none
+dotnet_diagnostic.IDE1006.severity = warning
+dotnet_diagnostic.IDE0055.severity = warning
+dotnet_diagnostic.RCS1021.severity = warning
+dotnet_diagnostic.SA1623.severity = none
+dotnet_diagnostic.MA0004.severity = none
+dotnet_diagnostic.SA1101.severity = none
+dotnet_diagnostic.SA1503.severity = none
+dotnet_diagnostic.SA1000.severity = none
+dotnet_diagnostic.SA1009.severity = none
+dotnet_diagnostic.RCS1194.severity = none
+dotnet_diagnostic.SA1106.severity = none
+dotnet_diagnostic.SA1602.severity = none
+dotnet_diagnostic.SA1601.severity = none
+dotnet_diagnostic.IDE0270.severity = warning
+dotnet_diagnostic.CA2016.severity = warning
+dotnet_diagnostic.MA0011.severity = none
+dotnet_sort_system_directives_first = true
+dotnet_separate_import_directive_groups = false
+
+# CS1591: Missing XML comment for publicly visible type or member
+dotnet_diagnostic.CS1591.severity = none
+dotnet_diagnostic.SA1500.severity = none
+dotnet_diagnostic.SA1502.severity = none
+dotnet_diagnostic.SA1508.severity = none
+dotnet_diagnostic.RCS0058.severity = warning
+dotnet_diagnostic.SA1309.severity = none
+dotnet_diagnostic.RCS0063.severity = warning
+dotnet_diagnostic.RCS1251.severity = warning
+dotnet_diagnostic.MA0089.severity = warning
+dotnet_diagnostic.RCS1163.severity = warning
+dotnet_diagnostic.SA1010.severity = none
+dotnet_diagnostic.IDE0300.severity = warning
+dotnet_diagnostic.MA0026.severity = none
+dotnet_diagnostic.S1135.severity = none
+dotnet_diagnostic.SA1011.severity = none
+dotnet_diagnostic.RCS1097.severity = warning
+dotnet_diagnostic.RCS1132.severity = warning
+dotnet_diagnostic.RCS1016.severity = warning
+dotnet_diagnostic.CA1000.severity = none
+dotnet_diagnostic.CA1716.severity = none
+dotnet_diagnostic.IDE0130.severity = warning
+dotnet_diagnostic.CA1711.severity = none
+dotnet_diagnostic.S2326.severity = none
+dotnet_diagnostic.RCS1214.severity = warning
+dotnet_diagnostic.MA0015.severity = none
+dotnet_diagnostic.MA0097.severity = none
+dotnet_diagnostic.IDE0008.severity = none
+dotnet_diagnostic.SA0001.severity = none
+dotnet_diagnostic.IDE0005.severity = none
+dotnet_diagnostic.MA0048.severity = none
+dotnet_diagnostic.IDE0011.severity = none
+dotnet_diagnostic.SA1118.severity = none
+
+[**/Data/Migrations/*.cs]
+generated_code = true
+dotnet_analyzer_diagnostic.severity = none
\ No newline at end of file
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 0000000..c4d6ae2
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,34 @@
+name: Main
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+jobs:
+
+ code-validation:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 11.0.x
+ dotnet-quality: preview
+ - name: Restore
+ run: dotnet restore
+ - name: Build
+ run: dotnet build --no-restore
+ - name: Test
+ run: dotnet test --no-build /p:CollectCoverage=true /p:ExcludeByAttribute="GeneratedCodeAttribute" /p:ExcludeByAttribute="CompilerGeneratedAttribute" /p:CoverletOutputFormat=\"json,cobertura\" /p:CoverletOutput=../../coverage/ /p:MergeWith=../../coverage/coverage.json
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v5
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ files: coverage/coverage.cobertura.xml
+ fail_ci_if_error: true
+ verbose: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..24b9edd
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,32 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - '*.*.*'
+jobs:
+
+ publish:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set current Tag
+ run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
+ - name: Check current Tag
+ run: |
+ echo $RELEASE_VERSION
+ echo ${{ env.RELEASE_VERSION }}
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 11.0.x
+ dotnet-quality: preview
+ - name: Generate Package
+ run: |
+ dotnet clean -c Release
+ dotnet build -c Release
+ dotnet test -c Release --no-build --verbosity normal
+ dotnet pack src/UnionResult/UnionResult.csproj --output ./nupkgs -c Release --no-build /p:Version=${{ env.RELEASE_VERSION }}
+ - name: Push to NuGet
+ run: |
+ dotnet nuget push ./nupkgs/*.nupkg -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ca328b8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,404 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+# but not Directory.Build.rsp, as it configures directory-level build defaults
+!Directory.Build.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.tlog
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio 6 auto-generated project file (contains which files were open etc.)
+*.vbp
+
+# Visual Studio 6 workspace and project file (working project files containing files to include in project)
+*.dsw
+*.dsp
+
+# Visual Studio 6 technical files
+*.ncb
+*.aps
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# Visual Studio History (VSHistory) files
+.vshistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+# VS Code files for those working on multiple tools
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# Local History for Visual Studio Code
+.history/
+
+# Windows Installer files from build outputs
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# JetBrains Rider
+*.sln.iml
+*.idea
+
+.qodo
+.qwen
\ No newline at end of file
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..6475f00
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,15 @@
+
+
+ net11.0
+ preview
+ enable
+ enable
+ latest
+ Recommended
+ true
+ true
+ true
+ all
+ moderate
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..33d5336
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,27 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index bb2140e..418c0e1 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,194 @@
-# UnionResult
\ No newline at end of file
+
+
+
+
+# UnionResult
+
+[](https://www.nuget.org/packages/UnionResult/)
+[](./LICENSE)
+[](https://dotnet.microsoft.com/)
+[](https://learn.microsoft.com/dotnet/csharp/language-reference/builtin-types/union)
+[](https://github.com/Adolfok3/UnionResult/actions/workflows/main.yml)
+[](https://github.com/Adolfok3/UnionResult/actions/workflows/main.yml)
+[](./tests/UnionResult.Tests)
+[](https://codecov.io/gh/Adolfok3/UnionResult)
+
+A Result pattern implementation for .NET, built on top of C#'s new native `union` types.
+
+## Why
+
+`UnionResult` isn't about functional programming for its own sake - it exists to avoid unnecessary exception overhead in everyday application code. Throwing and catching exceptions is expensive and meant for truly exceptional situations, not for outcomes an operation can expect and a caller should routinely handle (validation failures, "not found", a downstream call that failed). `Result`/`Result` let a method return both possible outcomes explicitly, so the caller decides how to react instead of wrapping every call in `try`/`catch`.
+
+Several packages already implement this pattern for .NET. What sets `UnionResult` apart is the implementation: instead of a hand-rolled discriminated-union struct, it's built directly on top of C#'s new native `union` type (introduced as a preview language feature in .NET 11) - success/failure is a real, compiler-recognized union with exhaustive pattern-matching support, not a struct that merely looks like one.
+
+## Requirements
+
+Union types are a **preview C# language feature**. To use this package your project needs:
+
+```xml
+net11.0
+preview
+```
+
+and the .NET 11 preview SDK.
+
+## Installation
+
+```bash
+dotnet add package UnionResult
+```
+
+Or via the Package Manager Console:
+
+```powershell
+Install-Package UnionResult
+```
+
+## Usage
+
+### `Result` - an operation that returns a value
+
+```csharp
+using UnionResult;
+
+Result Divide(int a, int b) =>
+ b == 0
+ ? Result.Failure(new DivideByZeroException())
+ : Result.Success(a / b);
+
+var result = Divide(10, 2);
+
+var message = result.IsSuccess
+ ? $"Result: {result.AsValue()}"
+ : $"Failed: {result.AsException().Message}";
+```
+
+### `Result` - an operation with no return value
+
+```csharp
+using UnionResult;
+
+Result Save(User user) =>
+ repository.TrySave(user, out var error)
+ ? Result.Success()
+ : Result.Failure(error);
+
+var result = Save(user);
+
+if (result.IsFailure)
+{
+ logger.LogError(result.AsException(), "Failed to save user");
+}
+```
+
+### Implicit conversions
+
+`Success`/`Failure` aren't the only way to build a result - since `Result` and `Result` are real C# unions, a value of a case type converts implicitly, so you can just `return` it directly:
+
+```csharp
+using UnionResult;
+
+Result Divide(int a, int b)
+{
+ if (b == 0)
+ {
+ return new DivideByZeroException(); // implicitly converts to Result
+ }
+
+ return a / b; // implicitly converts to Result
+}
+
+Result r = 42; // same conversion, assigned directly
+```
+
+The same applies to the failure case of the non-generic `Result` (there's no implicit conversion for "success with no value", so that side still calls `Result.Success()` explicitly):
+
+```csharp
+Result Save(User user)
+{
+ if (!repository.TrySave(user, out var error))
+ {
+ return error; // implicitly converts to Result
+ }
+
+ return Result.Success();
+}
+```
+
+### A more realistic example: replacing exceptions at a service boundary
+
+```csharp
+public interface IUserRepository
+{
+ Result GetById(int id);
+}
+
+public class UserService(IUserRepository repository)
+{
+ public string GetDisplayName(int id)
+ {
+ var result = repository.GetById(id);
+ return result.IsSuccess ? result.AsValue().Name : "Unknown user";
+ }
+}
+```
+
+Here `GetById` communicates "this can fail" through its return type. The caller isn't forced to guess whether the repository throws, and no `try`/`catch` is needed just to keep the app running when a lookup fails.
+
+### Pattern matching
+
+Because `Result` is a real C# union, it also supports exhaustive pattern matching:
+
+```csharp
+var message = result switch
+{
+ int value => $"Result: {value}",
+ Exception ex => $"Failed: {ex.Message}",
+};
+```
+
+### API reference
+
+| Member | Description |
+|---|---|
+| `Result.Success(T value)` / `Result.Success()` | Creates a successful result. |
+| `Result.Failure(Exception)` / `Result.Failure(Exception)` | Creates a failed result. Throws `ArgumentNullException` if `exception` is `null`. |
+| `IsSuccess` / `IsFailure` | Whether the result is a success or a failure. |
+| `AsValue()` *(`Result` only)* | Returns the success value, or throws `InvalidOperationException` if the result is a failure. |
+| `AsException()` | Returns the failure's exception, or throws `InvalidOperationException` if the result is a success. |
+| `TryGetValue(out T value)` / `TryGetValue(out Exception value)` | Non-throwing variants of `AsValue()`/`AsException()`. |
+| `HasValue` | Whether the result holds any case at all (`false` only for `default(Result)`). |
+
+## Benchmarks
+
+`UnionResult` was benchmarked against [`Divino.OperationResult`](https://github.com/victorDivino/operationResult), a similar Result-pattern package, across three scenarios: a value-type payload (`int`), a reference-type payload (`Product`), and no payload at all. Each scenario measures creating a result (`Create`) and reading an already-built one (`Read`), for both the success and failure paths.
+
+**Environment:** BenchmarkDotNet v0.15.8, Windows 11, AMD Ryzen 7 7800X3D 4.20GHz, .NET SDK 11.0.100-preview.5, in-process toolchain.
+
+| Category | Operation | UnionResult | OperationResult | Allocated | Winner |
+|---|---|---:|---:|:---:|---|
+| **Value type (int)** | Create Success | 0.0044 ns | 0.0000 ns | 0 B (both) | tie (noise) |
+| | Create Failure | 0.2049 ns | 0.6265 ns | 0 B (both) | UnionResult (~3x) |
+| | Read Success | 0.4157 ns | 0.0023 ns | 0 B (both) | OperationResult |
+| | Read Failure | 0.6244 ns | 0.2201 ns | 0 B (both) | OperationResult (~3x) |
+| **Reference type (Product)** | Create Success | 0.1977 ns | 0.2250 ns | 0 B (both) | tie |
+| | Create Failure | 0.2374 ns | 1.1618 ns | 0 B (both) | UnionResult (~5x) |
+| | Read Success | 0.6298 ns | 0.2062 ns | 0 B (both) | OperationResult (~3x) |
+| | Read Failure | 0.6286 ns | 0.2114 ns | 0 B (both) | OperationResult (~3x) |
+| **No payload** | Create Success | 0.0001 ns | 0.0024 ns | 0 B (both) | tie (noise) |
+| | Create Failure | 0.2248 ns | 0.6071 ns | 0 B (both) | UnionResult (~2.7x) |
+| | Read Success | 0.0327 ns | 0.0005 ns | 0 B (both) | tie (noise) |
+| | Read Failure | 0.6227 ns | 0.2338 ns | 0 B (both) | OperationResult (~2.7x) |
+
+**Takeaways:**
+
+- Neither package allocates in any scenario measured.
+- `UnionResult` matches or beats `OperationResult` on every `Create` scenario - notably ~3-5x faster creating a failure.
+- `OperationResult` is consistently faster to *read* an already-built result (~0.2-0.4 ns) - `AsValue()`/`AsException()` validate the result's state before returning, which is the cost of that safety check.
+- Values under ~0.03 ns aren't meaningful differences - that's the measurement floor of the benchmark itself.
+
+The benchmark source lives in [`benchmarks/UnionResult.Benchmarks`](./benchmarks/UnionResult.Benchmarks).
+
+## License
+
+MIT - see [LICENSE](./LICENSE).
diff --git a/UnionResult.slnx b/UnionResult.slnx
new file mode 100644
index 0000000..557898b
--- /dev/null
+++ b/UnionResult.slnx
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/icon.png b/assets/icon.png
new file mode 100644
index 0000000..4f044d8
Binary files /dev/null and b/assets/icon.png differ
diff --git a/benchmarks/UnionResult.Benchmarks/ExceptionOnlyResultBenchmarks.cs b/benchmarks/UnionResult.Benchmarks/ExceptionOnlyResultBenchmarks.cs
new file mode 100644
index 0000000..701d0e4
--- /dev/null
+++ b/benchmarks/UnionResult.Benchmarks/ExceptionOnlyResultBenchmarks.cs
@@ -0,0 +1,52 @@
+using BenchmarkDotNet.Attributes;
+using UnionResult;
+
+namespace UnionResult.Benchmarks;
+
+///
+/// Compares the non-generic Result from each package: no payload, just a
+/// success/failure signal plus (on failure) the causing exception.
+///
+[Config(typeof(InProcessConfig))]
+public class ExceptionOnlyResultBenchmarks
+{
+ private static readonly InvalidOperationException FailureException = new("boom");
+
+ private Result _unionSuccess;
+ private Result _unionFailure;
+ private OperationResult.Result _operationSuccess;
+ private OperationResult.Result _operationFailure;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _unionSuccess = Result.Success();
+ _unionFailure = Result.Failure(FailureException);
+ _operationSuccess = OperationResult.Result.Success();
+ _operationFailure = OperationResult.Result.Error(FailureException);
+ }
+
+ [Benchmark]
+ public bool UnionResultCreateSuccess() => Result.Success().IsSuccess;
+
+ [Benchmark]
+ public bool OperationResultCreateSuccess() => OperationResult.Result.Success().IsSuccess;
+
+ [Benchmark]
+ public Exception UnionResultCreateFailure() => Result.Failure(FailureException).AsException();
+
+ [Benchmark]
+ public Exception OperationResultCreateFailure() => OperationResult.Result.Error(FailureException).Exception!;
+
+ [Benchmark]
+ public bool UnionResultReadSuccess() => _unionSuccess.IsSuccess;
+
+ [Benchmark]
+ public bool OperationResultReadSuccess() => _operationSuccess.IsSuccess;
+
+ [Benchmark]
+ public Exception UnionResultReadFailure() => _unionFailure.AsException();
+
+ [Benchmark]
+ public Exception OperationResultReadFailure() => _operationFailure.Exception!;
+}
diff --git a/benchmarks/UnionResult.Benchmarks/InProcessConfig.cs b/benchmarks/UnionResult.Benchmarks/InProcessConfig.cs
new file mode 100644
index 0000000..0eb2f23
--- /dev/null
+++ b/benchmarks/UnionResult.Benchmarks/InProcessConfig.cs
@@ -0,0 +1,21 @@
+using BenchmarkDotNet.Configs;
+using BenchmarkDotNet.Diagnosers;
+using BenchmarkDotNet.Jobs;
+using BenchmarkDotNet.Toolchains.InProcess.Emit;
+
+namespace UnionResult.Benchmarks;
+
+///
+/// Runs benchmarks in-process instead of spawning a separate generated project.
+/// BenchmarkDotNet 0.15.x does not yet recognize the net11.0 preview runtime moniker,
+/// which makes its out-of-process SDK validation throw; running in-process skips
+/// that validation entirely while still using MemoryDiagnoser for allocation data.
+///
+public sealed class InProcessConfig : ManualConfig
+{
+ public InProcessConfig()
+ {
+ AddJob(Job.Default.WithToolchain(InProcessEmitToolchain.Instance));
+ AddDiagnoser(MemoryDiagnoser.Default);
+ }
+}
diff --git a/benchmarks/UnionResult.Benchmarks/Product.cs b/benchmarks/UnionResult.Benchmarks/Product.cs
new file mode 100644
index 0000000..55271d0
--- /dev/null
+++ b/benchmarks/UnionResult.Benchmarks/Product.cs
@@ -0,0 +1,3 @@
+namespace UnionResult.Benchmarks;
+
+public sealed record Product(int Id, string Name, decimal Price);
diff --git a/benchmarks/UnionResult.Benchmarks/Program.cs b/benchmarks/UnionResult.Benchmarks/Program.cs
new file mode 100644
index 0000000..4cf403e
--- /dev/null
+++ b/benchmarks/UnionResult.Benchmarks/Program.cs
@@ -0,0 +1,9 @@
+using BenchmarkDotNet.Running;
+using UnionResult.Benchmarks;
+
+BenchmarkRunner.Run(
+[
+ typeof(ValueTypeResultBenchmarks),
+ typeof(ReferenceTypeResultBenchmarks),
+ typeof(ExceptionOnlyResultBenchmarks),
+]);
diff --git a/benchmarks/UnionResult.Benchmarks/Properties/launchSettings.json b/benchmarks/UnionResult.Benchmarks/Properties/launchSettings.json
new file mode 100644
index 0000000..9714da5
--- /dev/null
+++ b/benchmarks/UnionResult.Benchmarks/Properties/launchSettings.json
@@ -0,0 +1,8 @@
+{
+ "profiles": {
+ "UnionResult.Benchmarks": {
+ "commandName": "Project",
+ "hotReloadEnabled": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/benchmarks/UnionResult.Benchmarks/ReferenceTypeResultBenchmarks.cs b/benchmarks/UnionResult.Benchmarks/ReferenceTypeResultBenchmarks.cs
new file mode 100644
index 0000000..bdb6693
--- /dev/null
+++ b/benchmarks/UnionResult.Benchmarks/ReferenceTypeResultBenchmarks.cs
@@ -0,0 +1,57 @@
+using BenchmarkDotNet.Attributes;
+using UnionResult;
+
+namespace UnionResult.Benchmarks;
+
+///
+/// Compares the two packages when T is a reference type (Product). Neither package
+/// needs to box a reference type, so this isolates the pure wrapping/reading overhead
+/// of each Result shape from the boxing cost measured in .
+/// Create and Read are benchmarked separately for the same reason as the value-type case:
+/// it keeps the two costs (producing vs consuming a Result) from being conflated. Both use
+/// the non-boxing accessor (AsValue()/.Value), matching normal API usage.
+///
+[Config(typeof(InProcessConfig))]
+public class ReferenceTypeResultBenchmarks
+{
+ private static readonly Product SuccessValue = new(1, "Keyboard", 199.90m);
+ private static readonly InvalidOperationException FailureException = new("boom");
+
+ private Result _unionSuccess;
+ private Result _unionFailure;
+ private OperationResult.Result _operationSuccess;
+ private OperationResult.Result _operationFailure;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _unionSuccess = Result.Success(SuccessValue);
+ _unionFailure = Result.Failure(FailureException);
+ _operationSuccess = OperationResult.Result.Success(SuccessValue);
+ _operationFailure = OperationResult.Result.Error(FailureException);
+ }
+
+ [Benchmark]
+ public Product UnionResultCreateSuccess() => Result.Success(SuccessValue).AsValue();
+
+ [Benchmark]
+ public Product OperationResultCreateSuccess() => OperationResult.Result.Success(SuccessValue).Value!;
+
+ [Benchmark]
+ public Exception UnionResultCreateFailure() => Result.Failure(FailureException).AsException();
+
+ [Benchmark]
+ public Exception OperationResultCreateFailure() => OperationResult.Result.Error(FailureException).Exception!;
+
+ [Benchmark]
+ public Product UnionResultReadSuccess() => _unionSuccess.AsValue();
+
+ [Benchmark]
+ public Product OperationResultReadSuccess() => _operationSuccess.Value!;
+
+ [Benchmark]
+ public Exception UnionResultReadFailure() => _unionFailure.AsException();
+
+ [Benchmark]
+ public Exception OperationResultReadFailure() => _operationFailure.Exception!;
+}
diff --git a/benchmarks/UnionResult.Benchmarks/UnionResult.Benchmarks.csproj b/benchmarks/UnionResult.Benchmarks/UnionResult.Benchmarks.csproj
new file mode 100644
index 0000000..895694f
--- /dev/null
+++ b/benchmarks/UnionResult.Benchmarks/UnionResult.Benchmarks.csproj
@@ -0,0 +1,17 @@
+
+
+
+ Exe
+ false
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/benchmarks/UnionResult.Benchmarks/ValueTypeResultBenchmarks.cs b/benchmarks/UnionResult.Benchmarks/ValueTypeResultBenchmarks.cs
new file mode 100644
index 0000000..5b0e38d
--- /dev/null
+++ b/benchmarks/UnionResult.Benchmarks/ValueTypeResultBenchmarks.cs
@@ -0,0 +1,62 @@
+using BenchmarkDotNet.Attributes;
+using UnionResult;
+
+namespace UnionResult.Benchmarks;
+
+///
+/// Compares the two packages when T is a value type (int). Result<T> stores T and
+/// Exception in their own typed fields behind a tag (the union "non-boxing access
+/// pattern" via TryGetValue), so AsValue()/AsException() never box; OperationResult.Result<T>
+/// likewise stores T directly in a typed field. Both packages still expose a boxing
+/// `object? Value` property (required by IUnion for UnionResult; OperationResult has no
+/// such requirement but its own `.Value` is already unboxed for value types), but neither
+/// AsValue() nor a normal consumer needs to go through it.
+///
+/// Create and Read are benchmarked separately so the cost of producing a Result isn't
+/// conflated with the cost of consuming one. Both go through the non-boxing accessor
+/// (AsValue()/.Value) so the numbers reflect how the API is actually meant to be used.
+///
+[Config(typeof(InProcessConfig))]
+public class ValueTypeResultBenchmarks
+{
+ private static readonly int SuccessValue = 42;
+ private static readonly InvalidOperationException FailureException = new("boom");
+
+ private Result _unionSuccess;
+ private Result _unionFailure;
+ private OperationResult.Result _operationSuccess;
+ private OperationResult.Result _operationFailure;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _unionSuccess = Result.Success(SuccessValue);
+ _unionFailure = Result.Failure(FailureException);
+ _operationSuccess = OperationResult.Result.Success(SuccessValue);
+ _operationFailure = OperationResult.Result.Error(FailureException);
+ }
+
+ [Benchmark]
+ public int UnionResultCreateSuccess() => Result.Success(SuccessValue).AsValue();
+
+ [Benchmark]
+ public int OperationResultCreateSuccess() => OperationResult.Result.Success(SuccessValue).Value;
+
+ [Benchmark]
+ public Exception UnionResultCreateFailure() => Result.Failure(FailureException).AsException();
+
+ [Benchmark]
+ public Exception OperationResultCreateFailure() => OperationResult.Result.Error(FailureException).Exception!;
+
+ [Benchmark]
+ public int UnionResultReadSuccess() => _unionSuccess.AsValue();
+
+ [Benchmark]
+ public int OperationResultReadSuccess() => _operationSuccess.Value;
+
+ [Benchmark]
+ public Exception UnionResultReadFailure() => _unionFailure.AsException();
+
+ [Benchmark]
+ public Exception OperationResultReadFailure() => _operationFailure.Exception!;
+}
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
new file mode 100644
index 0000000..bec06e4
--- /dev/null
+++ b/src/Directory.Build.props
@@ -0,0 +1,25 @@
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_Parameter1>$(MSBuildProjectName).Tests
+
+
+ <_Parameter1>DynamicProxyGenAssembly2
+
+
+
diff --git a/src/UnionResult/Result.cs b/src/UnionResult/Result.cs
new file mode 100644
index 0000000..f8a05bd
--- /dev/null
+++ b/src/UnionResult/Result.cs
@@ -0,0 +1,157 @@
+using System.Runtime.CompilerServices;
+
+namespace UnionResult;
+
+///
+/// Represents the outcome of an operation that returns a value of type
+/// : either a success carrying that value, or a failure
+/// carrying the that caused it. Use it in place of throwing for
+/// failures that are an expected, ordinary outcome of the operation - the caller decides
+/// how to react instead of having to catch an exception.
+///
+/// The type of the value returned on success.
+///
+///
+/// Result<int> Divide(int a, int b) =>
+/// b == 0
+/// ? Result<int>.Failure(new DivideByZeroException())
+/// : Result<int>.Success(a / b);
+///
+/// var result = Divide(10, 2);
+/// var message = result.IsSuccess
+/// ? $"Result: {result.AsValue()}"
+/// : $"Failed: {result.AsException().Message}";
+///
+///
+///
+/// Because is a C# union type, it also supports exhaustive
+/// pattern matching: result switch { int value => ..., Exception ex => ... }.
+///
+[Union]
+public readonly struct Result : IUnion
+{
+ private readonly T _value;
+ private readonly Exception? _exception;
+ private readonly State _state;
+
+ public Result(T value)
+ {
+ _value = value;
+ _state = State.Success;
+ }
+
+ public Result(Exception exception)
+ {
+ ArgumentNullException.ThrowIfNull(exception);
+
+ _value = default!;
+ _exception = exception;
+ _state = State.Failure;
+ }
+
+ private enum State : byte
+ {
+ Empty,
+ Success,
+ Failure,
+ }
+
+ public object? Value => _state switch
+ {
+ State.Success => _value,
+ State.Failure => _exception,
+ _ => null,
+ };
+
+ public bool HasValue => _state != State.Empty;
+
+ public bool IsSuccess => _state == State.Success;
+
+ public bool IsFailure => _state == State.Failure;
+
+ public static Result Success(T value) => new(value);
+
+ public static Result Failure(Exception exception) => new(exception);
+
+ public bool TryGetValue(out T value)
+ {
+ value = _value!;
+ return _state == State.Success;
+ }
+
+ public bool TryGetValue(out Exception value)
+ {
+ value = _exception!;
+ return _state == State.Failure;
+ }
+
+ public T AsValue() => ResultAccessor.OrThrow(
+ TryGetValue(out T value), value, "Result does not contain a value.");
+
+ public Exception AsException() => ResultAccessor.OrThrow(
+ TryGetValue(out Exception value), value, "Result does not contain an exception.");
+}
+
+///
+/// Represents the outcome of an operation that has no return value: either a plain
+/// success, or a failure carrying the that caused it. Use it in
+/// place of throwing for failures that are an expected, ordinary outcome of the
+/// operation - the caller decides how to react instead of having to catch an exception.
+///
+///
+///
+/// Result Save(User user) =>
+/// repository.TrySave(user, out var error)
+/// ? Result.Success()
+/// : Result.Failure(error);
+///
+/// var result = Save(user);
+/// if (result.IsFailure)
+/// {
+/// logger.LogError(result.AsException(), "Failed to save user");
+/// }
+///
+///
+[Union]
+public readonly struct Result : IUnion
+{
+ private readonly Exception? _exception;
+
+ public Result(Exception exception)
+ {
+ ArgumentNullException.ThrowIfNull(exception);
+
+ _exception = exception;
+ }
+
+ public object? Value => _exception;
+
+ public bool HasValue => _exception is not null;
+
+ public bool IsSuccess => _exception is null;
+
+ public bool IsFailure => _exception is not null;
+
+ public static Result Success() => default;
+
+ public static Result Failure(Exception exception) => new(exception);
+
+ public bool TryGetValue(out Exception value)
+ {
+ value = _exception!;
+ return _exception is not null;
+ }
+
+ public Exception AsException() => ResultAccessor.OrThrow(
+ TryGetValue(out Exception value), value, "Result does not contain an exception.");
+}
+
+///
+/// Shared "throw if not found" helper for the TryGetValue-based accessors above, so
+/// AsValue/AsException don't each repeat their own throw expression.
+///
+file static class ResultAccessor
+{
+ public static TValue OrThrow(bool found, TValue value, string message) =>
+ found ? value : throw new InvalidOperationException(message);
+}
diff --git a/src/UnionResult/UnionResult.csproj b/src/UnionResult/UnionResult.csproj
new file mode 100644
index 0000000..3ec16dc
--- /dev/null
+++ b/src/UnionResult/UnionResult.csproj
@@ -0,0 +1,12 @@
+
+
+ 1.0.0-preview.1
+ icon.png
+ README.md
+
+
+
+
+
+
+
diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props
new file mode 100644
index 0000000..4d515a2
--- /dev/null
+++ b/tests/Directory.Build.props
@@ -0,0 +1,25 @@
+
+
+
+ false
+ true
+ $(NoWarn);CA1707
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/UnionResult.Tests/ResultOfTTests.cs b/tests/UnionResult.Tests/ResultOfTTests.cs
new file mode 100644
index 0000000..7a6cb53
--- /dev/null
+++ b/tests/UnionResult.Tests/ResultOfTTests.cs
@@ -0,0 +1,216 @@
+using System.Diagnostics;
+
+namespace UnionResult.Tests;
+
+public class ResultOfTTests
+{
+ [Fact]
+ public void Success_ReturnsResultThatIsSuccessAndCarriesTheValue()
+ {
+ // Act
+ var result = Result.Success(42);
+
+ // Assert
+ result.IsSuccess.Should().BeTrue();
+ result.IsFailure.Should().BeFalse();
+ result.AsValue().Should().Be(42);
+ }
+
+ [Fact]
+ public void Failure_ReturnsResultThatIsFailureAndCarriesTheException()
+ {
+ // Arrange
+ var exception = new InvalidOperationException("boom");
+
+ // Act
+ var result = Result.Failure(exception);
+
+ // Assert
+ result.IsSuccess.Should().BeFalse();
+ result.IsFailure.Should().BeTrue();
+ result.AsException().Should().BeSameAs(exception);
+ }
+
+ [Fact]
+ public void Failure_WithNullException_ThrowsArgumentNullException()
+ {
+ // Act
+ var act = () => Result.Failure(null!);
+
+ // Assert
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void AsValue_OnFailure_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ var result = Result.Failure(new InvalidOperationException("boom"));
+
+ // Act
+ var act = result.AsValue;
+
+ // Assert
+ act.Should().Throw()
+ .WithMessage("Result does not contain a value.");
+ }
+
+ [Fact]
+ public void AsException_OnSuccess_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ var result = Result.Success(42);
+
+ // Act
+ var act = () => result.AsException();
+
+ // Assert
+ act.Should().Throw()
+ .WithMessage("Result does not contain an exception.");
+ }
+
+ [Fact]
+ public void Success_WithReferenceTypeValue_RoundTripsTheSameInstance()
+ {
+ // Arrange
+ var payload = new List { "a", "b" };
+
+ // Act
+ var result = Result>.Success(payload);
+
+ // Assert
+ result.AsValue().Should().BeSameAs(payload);
+ }
+
+ [Fact]
+ public void Success_WithDefaultStructValue_IsReadableAsValue()
+ {
+ // Act
+ var result = Result.Success(0);
+
+ // Assert
+ result.IsSuccess.Should().BeTrue();
+ result.AsValue().Should().Be(0);
+ }
+
+ [Fact]
+ public void Success_WithNullReferenceValue_IsReadableAsValue()
+ {
+ // The tag-based discriminator (rather than a "Value is T value" pattern check)
+ // means a success built from a null reference round-trips correctly.
+
+ // Act
+ var result = Result.Success(null);
+
+ // Assert
+ result.IsSuccess.Should().BeTrue();
+ result.AsValue().Should().BeNull();
+ }
+
+ [Fact]
+ public void Default_OfValueTypeResult_IsNeitherSuccessNorFailure()
+ {
+ // An uninitialized (default) Result has tag 0, which the tag-based
+ // discriminator reports as neither success nor failure.
+
+ // Act
+ var result = default(Result);
+ var asValue = result.AsValue;
+ var asException = result.AsException;
+
+ // Assert
+ result.IsSuccess.Should().BeFalse();
+ result.IsFailure.Should().BeFalse();
+ asValue.Should().Throw();
+ asException.Should().Throw();
+ }
+
+ [Fact]
+ public void Success_WhenTIsAnExceptionType_IsStillClassifiedAsSuccess()
+ {
+ // The tag-based discriminator (rather than "Value is Exception") means
+ // Result can safely wrap a T that is itself an Exception (or subtype).
+
+ // Arrange
+ var payload = new ArgumentException("payload, not a real failure");
+
+ // Act
+ var result = Result.Success(payload);
+
+ // Assert
+ result.IsSuccess.Should().BeTrue();
+ result.IsFailure.Should().BeFalse();
+ result.AsValue().Should().BeSameAs(payload);
+
+ var asException = result.AsException;
+ asException.Should().Throw();
+ }
+
+ [Fact]
+ public void Constructor_WithValue_IsEquivalentToSuccess()
+ {
+ // Act
+ var result = new Result(7);
+
+ // Assert
+ result.IsSuccess.Should().BeTrue();
+ result.AsValue().Should().Be(7);
+ }
+
+ [Fact]
+ public void Constructor_WithException_IsEquivalentToFailure()
+ {
+ // Arrange
+ var exception = new InvalidOperationException("boom");
+
+ // Act
+ var result = new Result(exception);
+
+ // Assert
+ result.IsFailure.Should().BeTrue();
+ result.AsException().Should().BeSameAs(exception);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(int.MaxValue)]
+ [InlineData(int.MinValue)]
+ public void Success_RoundTripsVariousIntValues(int value)
+ {
+ // Act
+ var result = Result.Success(value);
+
+ // Assert
+ result.AsValue().Should().Be(value);
+ }
+
+ [Fact]
+ public void Success_WithComplexObjectGraph_PreservesReferenceEquality()
+ {
+ // Arrange
+ var record = new Person("Ada", 36);
+
+ // Act
+ var result = Result.Success(record);
+
+ // Assert
+ result.AsValue().Should().Be(record);
+ result.AsValue().Should().BeSameAs(record);
+ }
+
+ [Fact]
+ public void Failure_PreservesTheConcreteExceptionType()
+ {
+ // Arrange
+ var exception = new UnreachableException("Some error message");
+
+ // Act
+ var result = Result.Failure(exception);
+
+ // Assert
+ result.AsException().Should().BeOfType();
+ }
+
+ private sealed record Person(string Name, int Age);
+}
diff --git a/tests/UnionResult.Tests/ResultTests.cs b/tests/UnionResult.Tests/ResultTests.cs
new file mode 100644
index 0000000..148a127
--- /dev/null
+++ b/tests/UnionResult.Tests/ResultTests.cs
@@ -0,0 +1,112 @@
+using System.Diagnostics;
+
+namespace UnionResult.Tests;
+
+public class ResultTests
+{
+ [Fact]
+ public void Success_ReturnsResultThatIsSuccess()
+ {
+ // Act
+ var result = Result.Success();
+
+ // Assert
+ result.IsSuccess.Should().BeTrue();
+ result.IsFailure.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Failure_ReturnsResultThatIsFailure()
+ {
+ // Arrange
+ var exception = new InvalidOperationException("boom");
+
+ // Act
+ var result = Result.Failure(exception);
+
+ // Assert
+ result.IsSuccess.Should().BeFalse();
+ result.IsFailure.Should().BeTrue();
+ }
+
+ [Fact]
+ public void AsException_OnFailure_ReturnsTheOriginalExceptionInstance()
+ {
+ // Arrange
+ var exception = new InvalidOperationException("boom");
+ var result = Result.Failure(exception);
+
+ // Act
+ var caught = result.AsException();
+
+ // Assert
+ caught.Should().BeSameAs(exception);
+ }
+
+ [Fact]
+ public void AsException_OnSuccess_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ var result = Result.Success();
+
+ // Act
+ var act = () => result.AsException();
+
+ // Assert
+ act.Should().Throw()
+ .WithMessage("Result does not contain an exception.");
+ }
+
+ [Fact]
+ public void Default_BehavesAsSuccess()
+ {
+ // Act
+ var result = default(Result);
+
+ // Assert
+ result.IsSuccess.Should().BeTrue();
+ result.IsFailure.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Failure_WithNullException_ThrowsArgumentNullException()
+ {
+ // Act
+ var act = () => Result.Failure(null!);
+
+ // Assert
+ act.Should().Throw();
+ }
+
+ [Theory]
+ [InlineData(typeof(ArgumentException))]
+ [InlineData(typeof(InvalidOperationException))]
+ [InlineData(typeof(NotSupportedException))]
+ public void Failure_PreservesTheConcreteExceptionType(Type exceptionType)
+ {
+ // Arrange
+ var exception = (Exception)Activator.CreateInstance(exceptionType)!;
+
+ // Act
+ var result = Result.Failure(exception);
+
+ // Assert
+ result.AsException().Should().BeOfType(exceptionType);
+ }
+
+ [Fact]
+ public void Failure_PreservesInnerExceptionAndMessage()
+ {
+ // Arrange
+ var inner = new UnreachableException("inner failure");
+ var exception = new InvalidOperationException("outer failure", inner);
+
+ // Act
+ var result = Result.Failure(exception);
+
+ // Assert
+ var caught = result.AsException();
+ caught.Message.Should().Be("outer failure");
+ caught.InnerException.Should().BeSameAs(inner);
+ }
+}
diff --git a/tests/UnionResult.Tests/ResultUsageScenariosTests.cs b/tests/UnionResult.Tests/ResultUsageScenariosTests.cs
new file mode 100644
index 0000000..3f412ca
--- /dev/null
+++ b/tests/UnionResult.Tests/ResultUsageScenariosTests.cs
@@ -0,0 +1,136 @@
+using NSubstitute.ExceptionExtensions;
+
+namespace UnionResult.Tests;
+
+///
+/// Exercises Result/Result<T> the way a consuming application would: a
+/// dependency mocked with NSubstitute returns a Result instead of throwing, and
+/// business logic branches on IsSuccess/IsFailure.
+///
+public class ResultUsageScenariosTests
+{
+ public interface IUserRepository
+ {
+ Result GetById(int id);
+
+ Result Save(User user);
+ }
+
+ [Fact]
+ public void GetDisplayName_WhenRepositoryReturnsSuccess_ReturnsUserName()
+ {
+ // Arrange
+ var repository = Substitute.For();
+ repository.GetById(1).Returns(Result.Success(new User(1, "Ada Lovelace")));
+ var service = new UserService(repository);
+
+ // Act
+ var name = service.GetDisplayName(1);
+
+ // Assert
+ name.Should().Be("Ada Lovelace");
+ repository.Received(1).GetById(1);
+ }
+
+ [Fact]
+ public void GetDisplayName_WhenRepositoryReturnsFailure_ReturnsFallback()
+ {
+ // Arrange
+ var repository = Substitute.For();
+ repository.GetById(99).Returns(Result.Failure(new KeyNotFoundException("user 99 not found")));
+ var service = new UserService(repository);
+
+ // Act
+ var name = service.GetDisplayName(99);
+
+ // Assert
+ name.Should().Be("Unknown user");
+ }
+
+ [Fact]
+ public void TrySave_WhenRepositorySucceeds_ReturnsTrueAndNoError()
+ {
+ // Arrange
+ var repository = Substitute.For();
+ repository.Save(Arg.Any()).Returns(Result.Success());
+ var service = new UserService(repository);
+
+ // Act
+ var saved = service.TrySave(new User(2, "Grace Hopper"), out var error);
+
+ // Assert
+ saved.Should().BeTrue();
+ error.Should().BeNull();
+ }
+
+ [Fact]
+ public void TrySave_WhenRepositoryFails_ReturnsFalseAndPropagatesException()
+ {
+ // Arrange
+ var repository = Substitute.For();
+ var dbException = new InvalidOperationException("constraint violation");
+ repository.Save(Arg.Any()).Returns(Result.Failure(dbException));
+ var service = new UserService(repository);
+
+ // Act
+ var saved = service.TrySave(new User(3, "Grace Hopper"), out var error);
+
+ // Assert
+ saved.Should().BeFalse();
+ error.Should().BeSameAs(dbException);
+ }
+
+ [Fact]
+ public void Repository_ThrowingInsteadOfReturningFailure_IsNotSwallowedByTheService()
+ {
+ // Result formalizes *expected* failures; it must not mask a dependency that
+ // throws instead of returning Result.Failure.
+
+ // Arrange
+ var repository = Substitute.For();
+ repository.GetById(Arg.Any()).Throws(new TimeoutException("db timeout"));
+ var service = new UserService(repository);
+
+ // Act
+ var act = () => service.GetDisplayName(1);
+
+ // Assert
+ act.Should().Throw().WithMessage("db timeout");
+ }
+
+ [Fact]
+ public void MultipleConfiguredCalls_EachArgumentYieldsItsOwnConfiguredResult()
+ {
+ // Arrange
+ var repository = Substitute.For();
+ repository.GetById(1).Returns(Result.Success(new User(1, "Ada Lovelace")));
+ repository.GetById(2).Returns(Result.Failure(new KeyNotFoundException()));
+ var service = new UserService(repository);
+
+ // Act
+ var nameForFirstUser = service.GetDisplayName(1);
+ var nameForSecondUser = service.GetDisplayName(2);
+
+ // Assert
+ nameForFirstUser.Should().Be("Ada Lovelace");
+ nameForSecondUser.Should().Be("Unknown user");
+ }
+
+ public sealed record User(int Id, string Name);
+
+ public sealed class UserService(IUserRepository repository)
+ {
+ public string GetDisplayName(int id)
+ {
+ var result = repository.GetById(id);
+ return result.IsSuccess ? result.AsValue().Name : "Unknown user";
+ }
+
+ public bool TrySave(User user, out Exception? error)
+ {
+ var result = repository.Save(user);
+ error = result.IsFailure ? result.AsException() : null;
+ return result.IsSuccess;
+ }
+ }
+}
diff --git a/tests/UnionResult.Tests/UnionResult.Tests.csproj b/tests/UnionResult.Tests/UnionResult.Tests.csproj
new file mode 100644
index 0000000..00e678f
--- /dev/null
+++ b/tests/UnionResult.Tests/UnionResult.Tests.csproj
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+