Skip to content

A few small types that make C# safer.

Kalicz.StrongTypes is a C# NuGet package that adds no-code validations enforced by the compiler. Invalid data never reaches your application code.

Install
dotnet add package Kalicz.StrongTypes

Why bother with strong types?

Stop validating input everywhere. Validate once, at the boundary, and let the compiler enforce the rest.

Diagram showing how StrongTypes pulls validation to the application boundary, leaving the rest of the code free of guard clauses

Invariants in the type

If a method takes NonEmptyString, it cannot receive an empty one. The compiler — not a runtime guard clause — does the work.

Validation at the edge

Built-in System.Text.Json converters reject invalid payloads during deserialization, before your endpoint method is even called.

Expressive intent

A signature like Result<Order, OrderError> Place(Positive<int> qty) tells the reader exactly what's allowed and what can fail.

Small and focused

No heavy framework, no runtime reflection magic. Drop it in alongside your existing code — types compose with what you already have.

What's in the box

Validated wrappers

  • NonEmptyString

    Guaranteed non-null, non-empty, non-whitespace string.

  • Positive<T>, NonNegative<T>, Negative<T>, NonPositive<T>

    Sign-enforcing wrappers over any INumber<T>.

  • NonEmptyEnumerable<T>

    Read-only sequence with at least one element — Head and Tail are always safe to access.

Algebraic types

  • Maybe<T>

    Optional values with Map and FlatMap. Useful for HTTP PATCH (don't touch / clear / set).

  • Result<T, TError>

    Explicit failure handling without exceptions. Includes async support.

Built-in integrations

  • System.Text.Json

    Automatic converters — no registration required.

  • EF Core value converters

    Via the Kalicz.StrongTypes.EfCore companion package.

  • FsCheck generators

    Via the Kalicz.StrongTypes.FsCheck companion package for property-based testing.

Diagram of the three NuGet packages: the core Kalicz.StrongTypes plus optional EfCore and FsCheck companion packages

How it looks

A few snippets to give you the feel of it. Full docs and more examples are on GitHub.

Validate at the API boundary

The JSON converter rejects invalid input during deserialization. Your endpoint never sees a bad value.

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

[HttpPost]
public async Task<IResult> Create(CreateUserRequest req)
{
    // No extra validation needed here:
    // ASP pipeline rejects invalid payloads with 400.
    _users.Add(req.Name, req.Age);
    await _users.SaveChangesAsync();
    return Results.NoContent();
}

Construct values explicitly

Each type has Create (throws on invalid) and TryCreate (returns null). String types also have a fluent extension form.

// Strings — three call styles
// throws if invalid
NonEmptyString name = NonEmptyString.Create(input);
// null if invalid
NonEmptyString? safe = NonEmptyString.TryCreate(input);
// fluent extension
NonEmptyString? fluent = input.AsNonEmpty();

// Numbers — sign-enforced over INumber<T>
Positive<int> qty = Positive<int>.Create(5);
NonNegative<decimal>? price = amount.AsNonNegative();

// Sequences — at least one element, Head safe
NonEmptyEnumerable<string> tags = ["red", "blue"];
NonEmptyEnumerable<string>? maybe = list.AsNonEmpty();
string first = tags.Head;
Flow diagram contrasting TryCreate (returns null on invalid input) and Create (throws on invalid input)

Explicit failures with Result<T, TError>

Pattern-match on Success or Error — no exceptions, no out parameters.

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

var result = Parse(input);
if (result.Success is { } value)
    Console.WriteLine($"got {value}");
if (result.Error is { } msg)
    Console.WriteLine($"failed: {msg}");
Pipeline diagram showing how a Result<T, TError> value flows through Map, MapError, and FlatMap stages

Three-state PATCH with Maybe<T>

Distinguish "don't touch", "clear to null", and "set to value" — a long-standing pain point in REST APIs.

public record PatchUser(Maybe<string>? Nickname);

// null         → don't touch the field
// Some(null)   → clear it
// Some("abc")  → set it
if (request.Nickname is { } change)
    user.Nickname = change.Value;
Diagram of the three-state PATCH model: null (don't touch), Some(null) (clear), Some(value) (set)

Compose with Maybe<T>

Chain optional operations without nested null checks. Implicit operators handle the wrapping — you return a T or Maybe.None and it just works.

// Producer: implicit conversion from T or Maybe.None
Maybe<int> Parse(string s) =>
    int.TryParse(s, out var n) ? n : Maybe.None;

// Map: T -> U, auto-wraps in Maybe
Maybe<int> doubled = Parse("21").Map(x => x * 2);
// Some(42)

// FlatMap: T -> Maybe<U>, no double-wrap
Maybe<int> sum = Parse("1").FlatMap(a =>
    Parse("2").Map(b => a + b));
// Some(3)
Pipeline diagram showing Some and None values flowing through Map and FlatMap operations on Maybe<T>

Get started

Source, packages, and docs.

Sign In

Don't have an account?

or