Skip to content

KaliCZ/StrongTypes

Repository files navigation

Kalicz.StrongTypes for C#

NuGet version Downloads License

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/whitespace
NonEmptyString  name = NonEmptyString.Create(input); // Throws ArgumentException
NonEmptyString? 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:

Type Invariant
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 invalid
Negative<int>?        n   = input.AsNegative();                // null if invalid
NonNegative<decimal> nn = NonNegative<decimal>.Create(input);  // throws if invalid
NonPositive<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:

bool stringEquality1 = NonEmptyString.Create("Alice") == "Alice"; // true - == overload against string
bool stringEquality2 = name.CompareTo("Alice") == 0;              // true - CompareTo overload against string

bool intEquality1 = 2 == Positive<int>.Create(2);                 // true - == overload against the number
bool intEquality2 = Positive<int>.Create(2) == 2;                 // true - == overload against the number

bool order = 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')
public sealed class RetryOptions
{
    public Positive<int> MaxRetries { get; set; }
    public NonEmptyString ApiName { get; set; } = null!;
    public string ApiKey { 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 → OptionsValidationException
builder.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:

<TextBox Text="{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).

var list = 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/null
NonEmptyEnumerable<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/null
NonEmptyEnumerable<int>  must  = values.ToNonEmpty();   // throws on empty/null

Guaranteed item accessible via the Head and Tail properties:

int                head  = list.Head;    // always defined = first item
IReadOnlyList<int> tail  = list.Tail;    // everything after Head
int                count = 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-empty
NonEmptyEnumerable<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?:

int max  = list.Max();                 // never throws, returns int (not int?)
int min  = list.Min();
int last = list.Last();
int sum  = list.Aggregate((a, b) => a + b);
int avg  = 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    = [new Dog()];
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, …).

Type Start End Use for
FiniteInterval<T> T T a fully-bounded range
IntervalFrom<T> T T? "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);    // null
FiniteInterval<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);
bool inRange = range.Contains(10);   // true — bounds are inclusive by default
bool atShiftEnd = 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.

string label = interval switch
{
    (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)

var stay = FiniteInterval.Create(new DateOnly(2026, 7, 4), new DateOnly(2026, 7, 24));
stay.Contains(new DateTime(2026, 7, 15, 14, 0, 0));      // We have helpers for checking DateOnly against DateTime and vice-versa
stay.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 implicitly
Interval<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 unbounded
FiniteInterval<int>? maybe = mixed[0].AsFinite();   // null when an endpoint is unbounded
FiniteInterval<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.

public sealed class HalfOpenIntervalConverter()
    : 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]
public enum Roles
{
    None   = 0,
    Reader = 1 << 0,
    Writer = 1 << 1,
    Admin  = 1 << 2,
}

// Factories, mirroring the framework's Parse/TryParse naming.
Roles  r1 = Roles.Parse("Reader");       // throws on failure
Roles? r2 = Roles.TryParse(userInput);   // null on failure
Roles? r3 = Roles.TryParse(userInput, ignoreCase: true);

// Same factories under the library's Create/TryCreate naming for
// consistency with NonEmptyString, Positive<T>, etc.
Roles  r4 = 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]
Roles                super = Roles.AllFlagsCombined;  // Reader | Writer | Admin

// Decompose a value into the single-bit flags it contains, in declaration order.
Roles user = Roles.Reader | Roles.Admin;
foreach (var flag in user.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:

NonEmptyString name = userInput.ToNonEmpty();   // throws ArgumentException
int            id   = queryParam.ToInt();       // throws FormatException / OverflowException
Roles          role = 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 step
MailAddress? email = text is null ? null : new MailAddress(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 => new MailAddress(t));
int? doubled = maybeInt.Map(x => x * 2);
SomeResult? something = featureFlagEnabled.MapTrue(CallSomeService);
// instead of
SomeResult? 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.Value is {} 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:

public record PatchRequest(
    Maybe<string>? NullableValue
);

[HttpPatch("{id:guid}")]
public async Task<IActionResult> Patch(Guid id, PatchRequest request)
{
    var entity = await Db.FindAsync<MyEntity>(id);
    if (entity is null) return NotFound();

    // null means the property was skipped. Empty means it's deliberaly set to null.
    if (request.NullableValue is { } nv)
        entity.NullableValue = nv.Value;

    await Db.SaveChangesAsync();
    return Ok();
}

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 argument
Maybe<int>    direct = 42;               // implicit conversion from T
Maybe<string> none   = Maybe.None;       // binds to whatever Maybe<T> the context expects
Maybe<int>    a      = nullableInt.ToMaybe();      // Some(x) when HasValue, None otherwise
Maybe<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.Value is { } v)
{
    // v is the underlying T — int (not int?), string (not string?)
}

For exhaustive handling, Match takes both branches:

var label = 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(string s) => int.TryParse(s, out var n) ? 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:

var sum =
    from a in Maybe<int>.Some(2)
    from b in Maybe<int>.Some(3)
    select a + b;                                            // Some(5)

var missing =
    from a in Maybe<int>.Some(2)
    from b in Maybe<int>.None        // short-circuits here
    from c in Maybe<int>.Some(10)    // never evaluated
    select a + 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:

public Result<int, string> Parse(string s)
{
    return int.TryParse(s, out var n)
        ? n
        : "not a number";
}

Explicit factories are there when type inference needs a nudge:

var ok  = Result.Success<int, string>(42);
var err = 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.Success is { } value) Console.WriteLine($"got {value}");
if (r.Error   is { } 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.Error is { } e)
{
    ModelState.AddModelError(nameof(request.PhoneNumber), MapPhoneErrorToApiCode(e));
    return ValidationProblem(ModelState);
}

This is what a service implementation can look like

public Result<Order, OrderError> CreateOrder(OrderData data)
{
    Result<Payment, PaymentError> paymentResult = Pay(data.Payment);
    if (paymentResult.Error is { } e)
    {
        logger.LogError("Failed to make a payment for order {OrderId} because of {ErrorReason}.", data.Id, e);
        return OrderError.PaymentFailed; // Implicit operator
    }

    return new Order(paymentResult.Success!);
}

// If the error is not important for you, simplify with Exceptions.
public Result<Order, OrderError> CreateOrder(OrderData data)
{
    Result<Payment, PaymentError> paymentResult = Pay(data.Payment);
    var payment = paymentResult.ThrowIfError(e => new Exception($"Payment failed because of {e}."));
    return new Order(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.
string message = 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 all
Result<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:

record User(NonEmptyString Name, Positive<int> Age);

Result<User, string> ParseUser(string? nameInput, int ageInput)
{
    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");

    return Result.Aggregate(name, age, // up to 8 parameters here
        (n, a) => new User(n, a),
        errors => string.Join("; ", errors));
}

// Or pass the list of errors out directly without any mapping
Result<User, string[]> x = Result.Aggregate(name, age, (n, a) => new User(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)
{
    return Result.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

Package Purpose Readme
Kalicz.StrongTypes Core 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.Configuration OptionsBuilder<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.EfCore EF Core value converters + DbContext extension for round-tripping the wrappers through scalar columns. readme
Kalicz.StrongTypes.FsCheck FsCheck Arbitrary<T> generators for property-based (generative) testing of code that takes or returns the wrappers. readme
Kalicz.StrongTypes.OpenApi.Microsoft Schema transformers for Microsoft.AspNetCore.OpenApi (AddOpenApi()) so the generated document matches the wire JSON. readme
Kalicz.StrongTypes.OpenApi.Swashbuckle Schema filters for Swashbuckle.AspNetCore (AddSwaggerGen()) so the generated Swagger document matches the wire JSON. readme
Kalicz.StrongTypes.AspNetCore MVC 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