Skip to content

Repository files navigation

Kalicz.StrongTypes for C#

NuGet versionDownloadsLicense

StrongTypes adds small, focused types that make everyday code safer and more expressive. The types ship with System.Text.Json converters out of the box, so invalid JSON fails at deserialization, and the scalar wrappers carry a TypeConverter, so the same invariant validates appsettings.json as it binds and WPF two-way bindings work with no setup. The types can be stored directly in EF Core entities via the EfCore package, and OpenAPI documentation is supported through the Microsoft or Swashbuckle OpenAPI packages — see Packages below.

🤖 Letting Claude Code or Codex write code in a project that uses StrongTypes? See Use with Claude or Codex below.

Impact of StrongTypes on validation boundaries

What's included

Package layout

Contents

Use with Claude or Codex

The skill lives in Skill/. Drop it under your project's .claude/skills/strongtypes/ (or ~/.claude/skills/strongtypes/ for user scope; swap .claude for .codex for Codex) and the agent picks it up. Each release ships a strongtypes-skill.tar.gz asset:

mkdir -p .claude/skills/strongtypes
curl -L https://github.com/KaliCZ/StrongTypes/releases/latest/download/strongtypes-skill.tar.gz \
| tar -xz -C .claude/skills/strongtypes

↑ Back to contents

Helpful Scalar types

The TryCreate / Create split (and the As… / To… extensions that mirror it) is used across every validated type in the library — pick the factory that matches how you want to handle bad input at the call site:

TryCreate vs Create flow

NonEmptyString

A string guaranteed to be non-null, non-empty, and not just whitespace. Construct it via the factory pair:

NonEmptyString?name=NonEmptyString.TryCreate(input);// null when null/empty/whitespaceNonEmptyStringname=NonEmptyString.Create(input);// Throws ArgumentExceptionNonEmptyString?name=userInput.AsNonEmpty();

NonEmptyString exposes the common string surface (Length, Contains, StartsWith, Substring, ToUpper, …) and implicitly converts to string.

↑ Back to contents

Numeric wrappers

Four generic wrappers that enforce a sign invariant on any INumber<T>int, long, short, decimal, float, double, and so on:

TypeInvariant
Positive<T>strictly greater than zero
NonNegative<T>greater than or equal to zero
Negative<T>strictly less than zero
NonPositive<T>less than or equal to zero

Same factory pattern:

Positive<int>?p=Positive<int>.TryCreate(input);// null if invalidNegative<int>?n=input.AsNegative();// null if invalidNonNegative<decimal>nn=NonNegative<decimal>.Create(input);// throws if invalidNonPositive<decimal>np=input.ToNonPositive();// throws if invalid

All defaults (e.g. default(Positive<T>)) still satisfy their invariants (e.g. default(Positive<int>) is 1, not an invalid 0).

↑ Back to contents

What you get for free

Every strong type in this library implements value-based equality, and the ordered ones (NonEmptyString, Digit, and the numeric wrappers) add the full comparison surface, so you can drop them into dictionaries, sorted collections, LINQ OrderBy, and equality checks without writing any boilerplate:

  • IEquatable<T> and the == / != operators
  • IComparable<T>, IComparable, and the <, <=, >, >= operators (on the ordered types)
  • GetHashCode and Equals(object?) overrides consistent with value-based equality
  • A sensible ToString() that returns the underlying value

Equality and comparison also work directly against the underlying value — there's no need to unwrap .Value first:

boolstringEquality1=NonEmptyString.Create("Alice")=="Alice";// true - == overload against stringboolstringEquality2=name.CompareTo("Alice")==0;// true - CompareTo overload against stringboolintEquality1=2==Positive<int>.Create(2);// true - == overload against the numberboolintEquality2=Positive<int>.Create(2)==2;// true - == overload against the numberboolorder=Positive<int>.Create(4)>2;// true - > overload against the number// Same for the other types and equality methods

↑ Back to contents

JSON serialization

The strong types ship with System.Text.Json converters attached via [JsonConverter] — no converter registration and no custom JsonSerializerOptions required. The format is the same as the underlying primitive ("hello", 42, …), and invalid input surfaces as a JsonException.

Maybe<T> has a special format of serialization, so Some serializes into { "Value": xxx } and None into { "Value": null }.

Result<T, TError> (and Result<T>) has no JSON converter — I don't think you want to serialize that.

↑ Back to contents

Configuration binding

The scalar strong types (NonEmptyString, Email, Digit, and the numeric wrappers) ship a TypeConverter attached via [TypeConverter], which is what ConfigurationBinder uses — so a wrapper sits directly on an options class and its invariant becomes the config validation rule. A bad value fails at startup with the path, the value, and the reason:

InvalidOperationException: Failed to convert configuration value '-5' at 'Retry:MaxRetries'
to type 'StrongTypes.Positive`1[System.Int32]'.
---> ArgumentException: Value must be positive, but was '-5'. (Parameter 'value')
publicsealedclassRetryOptions{publicPositive<int>MaxRetries{get;set;}publicNonEmptyStringApiName{get;set;}=null!;publicstringApiKey{get;set;}=null!;}// Standard C# behavior: ApiName and ApiKey can still be null. You'd need [Required] to prevent that.builder.Services.AddOptions<RetryOptions>().Bind(builder.Configuration.GetSection("Retry")).ValidateOnStart();// ApiName or ApiKey null → OptionsValidationExceptionbuilder.Services.AddOptions<RetryOptions>().BindStrongTypes(builder.Configuration.GetSection("Retry"))// A special method for preventing nulls..ValidateOnStart();

↑ Back to contents

EF Core persistence

If you want to store strong types directly on your EF Core entities, add the companion package Kalicz.StrongTypes.EfCore. It provides the value converters needed to map NonEmptyString, Positive<T>, and other numeric types to their underlying column types. See the package readme for setup details.

↑ Back to contents

OpenAPI / Swagger schema

By default, ASP.NET Core's spec generators describe strong-type wrappers by their CLR shape — NonEmptyString becomes an object with a Value field, Positive<int> a wrapper object, Maybe<T> the full union surface — so generated clients see nonsense and validation hints (minLength, minimum, …) never reach consumers. Two companion packages fix that, one per generator pipeline:

Pick the one that matches the generator your app already uses. They're not interchangeable — Microsoft.AspNetCore.OpenApi and Swashbuckle have disjoint extension points (IOpenApiSchemaTransformer vs ISchemaFilter), so the package built for one does nothing for the other.

Tip

If you have a free choice, prefer Swashbuckle. Microsoft.AspNetCore.OpenApi has a few rough edges that the framework gives no public hook to fix — for example [EmailAddress] doesn't surface as format: email, and a Dictionary<string, int> emits { "format": "int32", "pattern": "^-?(?:0|[1-9]\\d*)$" } for the int instead of { "type": "integer", "format": "int32" }. Swashbuckle exposes richer extension points and produces a faithful document in cases where the Microsoft pipeline silently drops the bound.

↑ Back to contents

WPF MVVM binding

Nothing to install, nothing to call. WPF resolves string → T through TypeDescriptor, and the scalar strong types carry a [TypeConverter], so two-way bindings just work:

<TextBoxText="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnExceptions=True}" />

…where Name is a view-model property of type NonEmptyString. ValidatesOnExceptions=True is the load-bearing piece: it turns the ArgumentException a strong type throws on invalid input into a ValidationError, driving WPF's standard red-border template. Without it the binding swallows the failure silently.

Supported the same way, with nothing to install or call: WPF, WinForms, and designers. MAUI and Avalonia aren't covered yet — see issue #94.

↑ Back to contents

NonEmptyEnumerable<T>

A read-only sequence guaranteed to contain at least one element. The non-empty invariant is enforced at construction and travels through operations that preserve it (Select, SelectMany, Distinct, Concat), so .Head is always defined — no empty-collection check needed (the value itself can still be null when T is nullable).

varlist=NonEmptyEnumerable.Create(1,2,3);NonEmptyEnumerable<int>list=[1,2,3];// collection expression works, but throws for empty []// CreateRange for runtime sequences (List<T>, LINQ queries, …).// `Create` name is taken by collection expression support. Therefore called `CreateRange`.NonEmptyEnumerable<int>throws=NonEmptyEnumerable.CreateRange(source);// throws on empty/nullNonEmptyEnumerable<int>?nullable=NonEmptyEnumerable.TryCreateRange(source);// null on empty/null// Just use the extension - it's nicer syntax.NonEmptyEnumerable<int>?maybe=values.AsNonEmpty();// null on empty/nullNonEmptyEnumerable<int>must=values.ToNonEmpty();// throws on empty/null

Guaranteed item accessible via the Head and Tail properties:

inthead=list.Head;// always defined = first itemIReadOnlyList<int>tail=list.Tail;// everything after Headintcount=list.Count;// always >= 1

LINQ operations that preserve the invariant return NonEmptyEnumerable<TResult>, so the guarantee doesn't decay through a chain:

NonEmptyEnumerable<int>doubled=list.Select(x =>x*2);NonEmptyEnumerable<int>distinct=list.Distinct();NonEmptyEnumerable<string>allTags=pages.SelectMany(p =>p.Tags);// p.Tags is itself non-emptyNonEmptyEnumerable<int>extended=list.Concat(10,20);NonEmptyEnumerable<int>reversed=list.Reverse();NonEmptyEnumerable<int>withEnds=list.Prepend(0).Append(99);NonEmptyEnumerable<int>combined=1.Concat(existing,more);// head + N tails → guaranteed non-empty

Operations whose result can be empty (Where, Skip, GroupBy, …) fall through to plain LINQ and return IEnumerable<T>. Re-wrap with AsNonEmpty() / ToNonEmpty() at the point where you need the guarantee again.

Non-emptiness is also exactly the precondition LINQ's aggregate helpers need. The overloads on NonEmptyEnumerable<T> are total — they never throw InvalidOperationException on empty input and, for value types, return T directly instead of T?:

intmax=list.Max();// never throws, returns int (not int?)intmin=list.Min();intlast=list.Last();intsum=list.Aggregate((a,b)=>a+b);intavg=list.Average();

INonEmptyEnumerable<T> (covariant interface)

NonEmptyEnumerable<T> implements INonEmptyEnumerable<out T>, a covariant interface — use it when you need to assign a more-derived collection to a less-derived reference:

NonEmptyEnumerable<Dog>dogs=[newDog()];INonEmptyEnumerable<Animal>animals=dogs;// allowed thanks to `out T`
Covariant out T upcast

All extensions (Select, Concat, Max, Last, …) accept both the concrete type and the interface as the receiver, so either type works in a chain.

↑ Back to contents

JSON

Serializes as a JSON array; an empty JSON array is rejected with JsonException. The converter is attached via [JsonConverter], so no registration or custom JsonSerializerOptions is needed. NonEmptyEnumerable<T?> accepts JSON nulls as legitimate elements — [1, null, 3] round-trips faithfully into NonEmptyEnumerable<int?>.

Null elements in reference-typed collections — a JSON array like [null] deserializes successfully into NonEmptyEnumerable<string> or NonEmptyEnumerable<NonEmptyString> even though the element type isn't annotated nullable. The same would happen with a plain List<string>.

The same converter also serves INonEmptyEnumerable<T>, so properties typed as the interface round-trip the same way — deserialization still produces a concrete NonEmptyEnumerable<T> behind the interface reference.

↑ Back to contents

Interval types

A readonly struct with a condition Start <= End. Both are inclusive by default, but can be configured (see below). T may be any struct, IComparable<T> (int, DateTime, DateOnly, decimal, …).

TypeStartEndUse for
FiniteInterval<T>TTa fully-bounded range
IntervalFrom<T>TT?"from X" (end optional)
IntervalUntil<T>T?T"until Y" (start optional)
Interval<T>T?T?both optional (may be fully unbounded)

Same factory pattern; TryCreate / Create reject Start > End. Start = End is rejected unless both are inclusive.

FiniteInterval<int>?good=FiniteInterval.TryCreate(1,10);// [1, 10]FiniteInterval<int>?bad=FiniteInterval.TryCreate(10,1);// nullFiniteInterval<int>time=FiniteInterval.Create(9,17,endInclusive:false);// [9, 17)IntervalFrom<DateOnly>openEnded=IntervalFrom.Create(today,null);// "from today, no end"

Contains answers membership honoring each bound's inclusivity.

FiniteInterval<int>range=FiniteInterval.Create(1,10);boolinRange=range.Contains(10);// true — bounds are inclusive by defaultboolatShiftEnd=time.Contains(17);// false — created with endInclusive: false

Deconstruct lets Interval<T> drive an exhaustive switch over the four bound cases. Deconstruct is defined on all 4 types.

stringlabel=intervalswitch{(null,null)=>"unbounded",(null,{}end)=>$"up to {end}",({}start,null)=>$"from {start}",({}start,{}end)=>$"{start}..{end}",};

Overlaps / GetOverlap intersect any variant mix (touching endpoints overlap only when both bounds are inclusive), and DateTimeDateOnly intervals bridge across:

time.GetOverlap(IntervalFrom.Create(15,null));// [15, 17)varstay=FiniteInterval.Create(newDateOnly(2026,7,4),newDateOnly(2026,7,24));stay.Contains(newDateTime(2026,7,15,14,0,0));// We have helpers for checking DateOnly against DateTime and vice-versastay.Days();// amount of days - respects inclusivity of both start+end.

A more-constrained variant widens implicitly to a less-constrained one (lossless, never throws); narrowing is partial, so it follows the As… / To… convention:

IntervalFrom<int>from=FiniteInterval.Create(1,10);// widens implicitlyInterval<int>[]mixed=[FiniteInterval.Create(1,10),from];// implicit operators - all intervals can be added to an array of Interval<T>FiniteInterval<int>bad=mixed[0];// does NOT compile — an endpoint may be unboundedFiniteInterval<int>?maybe=mixed[0].AsFinite();// null when an endpoint is unboundedFiniteInterval<int>sure=mixed[0].ToFinite();// throws when an endpoint is unbounded

JSON

Serialized as an object. Both start + end are always written (even null value). Inclusivity defaults to true and skips writing when true.

{ "Start": 9, "End": 17 } // [9, 17] — flags omitted, so inclusive
{ "Start": 9, "End": 17, "EndInclusive": false } // [9, 17)

Missing value reads as null (so Interval accepts {}, but FiniteInterval would crash); The error is JsonException. Use a subclass of IntervalJsonConverter<TInterval> to change the default inclusivity and apply it per property.

publicsealedclassHalfOpenIntervalConverter():IntervalJsonConverter<FiniteInterval<int>>(IntervalBoundMode.AlwaysInclusive,IntervalBoundMode.AlwaysExclusive);

Persistence

Store into a DB with EF Core as two (indexable) columns or as one JSON column. Two columns are inclusive-only; JSON keeps each value's inclusivity.

// Default: two endpoint columns (inclusive-only).// Name the columns explicitly.modelBuilder.Entity<Booking>().HasIntervalColumns(b =>b.Window,startName:"WindowStart",endName:"WindowEnd");// Need the inclusivity stored? A JSON column keeps each value's own bounds.modelBuilder.Entity<Booking>().HasIntervalJsonConversion(b =>b.Archived);

To keep inclusivity in relational columns, model the endpoints yourself and expose an unmapped computed interval — see the EF Core package readme.

↑ Back to contents

Parsing helpers

Enums

Extension members on any enum type give you cached metadata, factories, and flag helpers. Everything hangs off the enum type itself, so you call Roles.Parse(...) rather than EnumExtensions.Parse<Roles>(...).

[Flags]publicenumRoles{None=0,Reader=1<<0,Writer=1<<1,Admin=1<<2,}// Factories, mirroring the framework's Parse/TryParse naming.Rolesr1=Roles.Parse("Reader");// throws on failureRoles?r2=Roles.TryParse(userInput);// null on failureRoles?r3=Roles.TryParse(userInput,ignoreCase:true);// Same factories under the library's Create/TryCreate naming for// consistency with NonEmptyString, Positive<T>, etc.Rolesr4=Roles.Create("Reader");Roles?r5=Roles.TryCreate(userInput);// All declared members, cached on first read. Fine to call in hot paths.IReadOnlyList<Roles>every=Roles.AllValues;// [None, Reader, Writer, Admin]

For [Flags] enums you also get bit-level helpers. AllFlagValues lists just the single-bit members (excluding None = 0 and composites), AllFlagsCombined OR-s them into an "everything on" value so you don't have to maintain a SuperAdmin = Reader | Writer | Admin literal.

IReadOnlyList<Roles>flags=Roles.AllFlagValues;// [Reader, Writer, Admin]Rolessuper=Roles.AllFlagsCombined;// Reader | Writer | Admin// Decompose a value into the single-bit flags it contains, in declaration order.Rolesuser=Roles.Reader|Roles.Admin;foreach(varflaginuser.GetFlags()){// flag is Reader, then Admin}

The flag helpers throw InvalidOperationException if the enum isn't marked [Flags], so a typo at the declaration fails loudly at the first call instead of silently returning the wrong thing.

↑ Back to contents

Strings

A small set of extension methods over string? for safe, nullable-returning parses:

NonEmptyString?name=userInput.AsNonEmpty();int?id=queryParam.AsInt();decimal?amt=body.AsDecimal();DateTime?when=header.AsDateTime();Roles?role=header.AsEnum<Roles>();

Each As* helper has a To* sibling that throws instead of returning null — pick the one that matches how you want to handle bad input at the call site:

NonEmptyStringname=userInput.ToNonEmpty();// throws ArgumentExceptionintid=queryParam.ToInt();// throws FormatException / OverflowExceptionRolesrole=header.ToEnum<Roles>();// throws ArgumentException

AsEnum<TEnum> / ToEnum<TEnum> work through an open generic TEnum parameter, which the Roles.TryParse(...) extension member can't — use them when you only know the enum type generically.

↑ Back to contents

Algebraic types

StrongTypes is not an attempt to build a full algebraic type system. The purpose of these types is just to help where C# functionality is lacking, not to invent a framework and work fully in the algebraic types.

These types enable quite a few simplifications when it comes to parsing and validations. But I wouldn't recommend building the whole app by composing them. They're meant to bridge small local pieces of the application. Let's start by introducing some functionality so we don't need the algebraic types in the first place.

Note

No discriminated union / OneOf type is included. I didn't see a reason to reinvent one — mcintyre321/OneOf or domn1995/dunet already cover this space well, and .NET 11 is expected to introduce native discriminated unions at the language level. If you have a concrete use case where neither option works for you, please open a GitHub issue and let me know.

Prefer nullables: Map, MapTrue, MapFalse

C# already lets you read through a null — user?.Name?.Trim() short-circuits without a single if. What was missing was passing a nullable into a function that expects the non-null form (e.g. a constructor). Historically that meant a ternary at every call, which is hard to chain and clutters up any expression it appears in:

// Before — one ternary for every stepMailAddress?email=textisnull?null:newMailAddress(text);

Map on T? and MapTrue / MapFalse on bool close those gaps with a single method call. The mapper only runs when the input is present (or the bool matches), and the null short-circuit is implicit:

MailAddress?email=text.Map(t =>newMailAddress(t));int?doubled=maybeInt.Map(x =>x*2);SomeResult?something=featureFlagEnabled.MapTrue(CallSomeService);// instead ofSomeResult?something=null;if(featureFlagEnabled)something=CallSomeService();

So you don't need Maybe<T> just to compose an optional logic. With Map in the toolbox, plain T? covers most cases. Save Maybe<T> for the cases where nullability can't express what you need — see the HTTP PATCH example below.

And when you already have a Maybe<T> or some other wrapper, you can step back out into nullable-land by just using the inner value — Maybe<T>.Value is itself a T?, so the same Map works:

Maybe<string>maybeName=LookupName(id);string?normalized=maybeName.Value.Map(n =>n.Trim().ToUpperInvariant());// Or alternatively with standard C#string?normalized=maybeName.Valueis{}n?n.Trim().ToUpperInvariant():null;

Warning

Map / MapTrue / MapFalse are slower than the equivalent ternary. The mapper is passed as a delegate, so the JIT has to go through a function-pointer invocation instead of the direct branch it gets from a ?:. Prefer the ternary on hot paths; reach for Map where readability matters more than the nanoseconds.

↑ Back to contents

Maybe<T>

A struct that holds either a value of T (Some) or no value (None). Works for both reference and value types and integrates with collection expressions, LINQ, pattern matching, and System.Text.Json.

The generic constraint is where T : notnullMaybe<int?> and Maybe<string?> are deliberately disallowed, because permitting a nullable T would collapse the None and Some(null) cases and break the is { } v unwrap pattern. (see more below)

Why? HTTP PATCH with optional properties

HTTP PATCH has a long-standing modelling problem for nullable fields: a request needs to distinguish three intents — don't touch this field, clear this field to null, and set it to a new value. A plain T? collapses the first two cases. Maybe<T>? keeps them apart, because Maybe<T> itself is a value, so wrapping it in T? adds a real third state:

PATCH three-state model

The request DTO and PATCH handler then read straight off pattern matching, with no out-of-band sentinel values:

publicrecordPatchRequest(Maybe<string>?NullableValue);[HttpPatch("{id:guid}")]publicasyncTask<IActionResult>Patch(Guidid,PatchRequestrequest){varentity=awaitDb.FindAsync<MyEntity>(id);if(entityisnull)returnNotFound();// null means the property was skipped. Empty means it's deliberaly set to null.if(request.NullableValueis{}nv)entity.NullableValue=nv.Value;awaitDb.SaveChangesAsync();returnOk();}

The StrongTypes.Api project in this repo uses exactly this pattern — see StructTypeEntityControllerBase.Patch and StructEntityPatchRequest for the production version that round-trips through both SQL Server and PostgreSQL.

Creating

Maybe<int>some=Maybe.Some(42);// T inferred from the argumentMaybe<int>direct=42;// implicit conversion from TMaybe<string>none=Maybe.None;// binds to whatever Maybe<T> the context expectsMaybe<int>a=nullableInt.ToMaybe();// Some(x) when HasValue, None otherwiseMaybe<string>b=nullableString.ToMaybe();// Some(x) when not null, None otherwise

The implicit conversions from T and from the untyped Maybe.None let collection expressions mix plain values, None markers, and spread sequences without spelling out Maybe<int>.Some(...) on every element:

int[]middle=[4,2,3];Maybe<int>[]xs=[..middle,Maybe.None,4];IEnumerable<int>values=xs.Values();// [4, 2, 3, 4]

Unwrapping

The idiomatic "has value" check uses the is { } v pattern on the Value extension property — Value returns Nullable<T> for value types and T? for reference types, and the pattern unwraps to the underlying T directly:

if(maybe.Valueis{}v){// v is the underlying T — int (not int?), string (not string?)}

For exhaustive handling, Match takes both branches:

varlabel=maybe.Match(ifSome: x =>$"got {x}",ifNone:()=>"nothing");

Composition

Maybe<T> composes monadically through Map, FlatMap, and Where. Each operation is a no-op on None, so chains short-circuit cleanly without explicit null checks:

Maybe composition pipeline
// Map — transform the inner value when present.Maybe<int>doubled=Maybe.Some(3).Map(x =>x*2);// Some(6)Maybe<int>stillNone=Maybe<int>.None.Map(x =>x*2);// None// FlatMap — chain an operation that itself returns a Maybe, without nesting.Maybe<int>Parse(strings)=>int.TryParse(s,outvarn)?Maybe.Some(n):Maybe<int>.None;Maybe<int>good=Maybe.Some("42").FlatMap(Parse);// Some(42)Maybe<int>bad=Maybe.Some("nope").FlatMap(Parse);// None// Where — keep the value only if it satisfies the predicate.Maybe<int>even=Maybe.Some(4).Where(x =>x%2==0);// Some(4)Maybe<int>dropped=Maybe.Some(5).Where(x =>x%2==0);// None

LINQ query syntax is supported through Select / SelectMany, and a single None anywhere in the chain empties the whole expression:

varsum=fromainMaybe<int>.Some(2)frombinMaybe<int>.Some(3)selecta+b;// Some(5)varmissing=fromainMaybe<int>.Some(2)frombinMaybe<int>.None// short-circuits herefromcinMaybe<int>.Some(10)// never evaluatedselecta+b+c;// None

JSON

Maybe<T> serializes via System.Text.Json as { "Value": x } for Some and { "Value": null } for None — no converter registration or custom JsonSerializerOptions needed. Deserialization also accepts {} for None, so callers can omit the property entirely.

↑ Back to contents

Result<T, TError>

Result<T, TError> is either a success carrying a T or an error carrying a TError — making the failure path explicit in the type signature instead of hiding it behind exceptions. Result<T> is shorthand for Result<T, Exception>, so signatures can read public Result<User> Load(...) without naming the error type.

Unlike the other types, Result has no JSON converter. I didn't see value in making one, because the format would be awkward anyway.

Construction

Returning a Result is as simple as returning either branch — implicit operators handle the wrapping on both sides:

publicResult<int,string>Parse(strings){returnint.TryParse(s,outvarn)?n:"not a number";}

Explicit factories are there when type inference needs a nudge:

varok=Result.Success<int,string>(42);varerr=Result.Error<int,string>("bad");

Inspection

Pattern matching with is { } v unwraps the inner value in one expression:

Result<int,string>r=Parse(input);if(r.Successis{}value)Console.WriteLine($"got {value}");if(r.Erroris{}msg)Console.WriteLine($"failed: {msg}");

IsSuccess / IsError are there too when you don't need the payload.

The expected usage in controllers is going to look something like this:

Result<PhoneNumber,PhoneNumberError>phoneResult=Parse(request.PhoneNumber);if(phoneResult.Erroris{}e){ModelState.AddModelError(nameof(request.PhoneNumber),MapPhoneErrorToApiCode(e));returnValidationProblem(ModelState);}

This is what a service implementation can look like

publicResult<Order,OrderError>CreateOrder(OrderDatadata){Result<Payment,PaymentError>paymentResult=Pay(data.Payment);if(paymentResult.Erroris{}e){logger.LogError("Failed to make a payment for order {OrderId} because of {ErrorReason}.",data.Id,e);returnOrderError.PaymentFailed;// Implicit operator}returnnewOrder(paymentResult.Success!);}// If the error is not important for you, simplify with Exceptions.publicResult<Order,OrderError>CreateOrder(OrderDatadata){Result<Payment,PaymentError>paymentResult=Pay(data.Payment);varpayment=paymentResult.ThrowIfError(e =>newException($"Payment failed because of {e}."));returnnewOrder(payment);}

Transformation

Map, MapError, Match, and FlatMap let you chain without explicit branching. Success and error values flow down independent tracks — Map only touches the success side, MapError only touches the error side, and FlatMap short-circuits on error:

Result composition pipeline
Result<int,string>r=Parse("42");// Map — transform the success value; errors pass through unchanged.Result<int,string>doubled=r.Map(x =>x*2);// Match — fold both branches into a single value.stringmessage=r.Match(success: x =>$"got {x}",error: e =>$"oops: {e}");// FlatMap — chain an operation that itself returns a Result.Result<int,string>positive=r.FlatMap<int>(x =>x>0?x:"must be positive");

Match exists because the natural-looking C# form — r switch { T v => …, TError e => … } — isn't possible yet.

Every sync method has an async counterpart (MapAsync, FlatMapAsync, MatchAsync, …).

Wrapping exceptions

Result.Catch captures a throwing call without writing try/catch:

Result<string>contents=Result.Catch(()=>File.ReadAllText(path));// catch allResult<int,FormatException>parsed=Result.Catch<int,FormatException>(()=>int.Parse(input));// only format Exception

By default OperationCanceledException (and its TaskCanceledException subtype) is not captured — cancellation unwinds the way it normally would, instead of turning into a Result error. Opt in with propagateCancellation: false when you do want cancellation observed as an error:

Result<string>contents=Result.Catch(()=>File.ReadAllText(path),propagateCancellation:false);

Every overload has an async counterpart (CatchAsync) with the same optional propagateCancellation parameter.

Combining validations

Result.Aggregate combines multiple results, collecting every error (not just the first) — which is what you want when validating an input:

recordUser(NonEmptyStringName,Positive<int>Age);Result<User,string>ParseUser(string?nameInput,intageInput){Result<NonEmptyString,string>name=nameInput.AsNonEmpty()// NonEmptyString?.ToResult("name must not be empty");Result<Positive<int>,string>age=ageInput.AsPositive()// Positive<int>?.ToResult("age must be positive");returnResult.Aggregate(name,age,// up to 8 parameters here(n,a)=>newUser(n,a),
errors =>string.Join("; ",errors));}// Or pass the list of errors out directly without any mappingResult<User,string[]>x=Result.Aggregate(name,age,(n,a)=>newUser(n,a));

You can pass an IEnumerable when the count is dynamic — useful for validating a list of inputs:

Result<Positive<int>[],string>ParseOrderQuantities(IEnumerable<int>inputs){returnResult.Aggregate(inputs.Select(i =>i.AsPositive().ToResult(i)),// Result<Positive<int>, int>
invalidNumbers =>$"Some numbers are not positive: [{string.Join(", ",invalidNumbers)}]");}

↑ Back to contents

Packages

PackagePurposeReadme
Kalicz.StrongTypesCore types: NonEmptyString, Email, Digit, Positive<T> / NonNegative<T> / Negative<T> / NonPositive<T>, the interval types, NonEmptyEnumerable<T>, Maybe<T>, Result<T, TError>, plus the System.Text.Json converters and TypeConverters.(this readme)
Kalicz.StrongTypes.ConfigurationOptionsBuilder<T>.BindStrongTypes() — binds a section and fails when a property declared non-nullable is null once bound, which an absent key otherwise leaves silently. Binding itself needs no package; this is only about the key that isn't there.readme
Kalicz.StrongTypes.EfCoreEF Core value converters + DbContext extension for round-tripping the wrappers through scalar columns.readme
Kalicz.StrongTypes.FsCheckFsCheck Arbitrary<T> generators for property-based (generative) testing of code that takes or returns the wrappers.readme
Kalicz.StrongTypes.OpenApi.MicrosoftSchema transformers for Microsoft.AspNetCore.OpenApi (AddOpenApi()) so the generated document matches the wire JSON.readme
Kalicz.StrongTypes.OpenApi.SwashbuckleSchema filters for Swashbuckle.AspNetCore (AddSwaggerGen()) so the generated Swagger document matches the wire JSON.readme
Kalicz.StrongTypes.AspNetCoreMVC model binder for NonEmptyEnumerable<T> from [FromForm], [FromQuery], [FromHeader], and [FromRoute], plus opt-out normalization of JSON request-body validation error keys ($.valueValue, configurable via AddStrongTypes(o => o.NormalizeJsonErrorKeys = …)). The binder isn't needed for JSON APIs — [FromBody] round-trips wrappers via the main package's JSON converters — but the error-key normalization applies to any JSON API.readme

↑ Back to contents

Acknowledgments

This library is vaguely based on FuncSharp by Honza Siroky, bringing some of the concepts into modern C# and .NET.

Licensed under the MIT License.

↑ Back to contents

Releases

Contributors

Languages