Work in Progress - StrongTypes is the continuation of FuncSharp, originally written by Honza Siroky, bringing the concepts into modern C#. It targets .NET 10 with nullable reference types enabled and leans on modern language features to deliver extra types that enable a better developer experience.
StrongTypes adds small, focused value types to C# that make everyday code safer and more expressive — things like "a string that is never empty" or "an integer that is always positive". Instead of validating the same invariant at every call site, you validate once at the boundary and pass the strong type onwards. The compiler then guarantees the invariant holds wherever that type appears.
A string guaranteed to be non-null, non-empty, and not just whitespace. Construct it via the factory pair:
// Returns null when the input is null/empty/whitespace — caller handles the null case.
NonEmptyString? maybe = NonEmptyString.TryCreate(input);
// Throws ArgumentException on invalid input.
NonEmptyString name = NonEmptyString.Create(input);Or via the AsNonEmpty() extension on any string?:
NonEmptyString? name = userInput.AsNonEmpty();NonEmptyString exposes the common string surface (Length, Contains, StartsWith, Substring, ToUpper, etc.) and implicitly converts to string, so it drops into existing APIs without friction.
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(quantity);
Positive<decimal> amt = Positive<decimal>.Create(price);
NonNegative<int>? age = NonNegative<int>.TryCreate(years);Or via the AsPositive(), AsNonNegative(), AsNegative(), and AsNonPositive() extensions on any INumber<T> — mirroring AsNonEmpty() on string?. Each returns null when the invariant isn't met:
Positive<int>? p = quantity.AsPositive();
NonNegative<int>? age = years.AsNonNegative();
Negative<int>? debt = balance.AsNegative();
NonPositive<decimal>? loss = pnl.AsNonPositive();When you'd rather fail loudly at the boundary than deal with null, the To… variants throw ArgumentException on invariant violation — same relationship as Create vs TryCreate:
Positive<int> p = quantity.ToPositive(); // throws if quantity <= 0
NonNegative<int> age = years.ToNonNegative();
Negative<int> debt = balance.ToNegative();
NonPositive<decimal> loss = pnl.ToNonPositive();The structs are laid out so that default(Positive<T>) still satisfies the invariant (e.g. default(Positive<int>) is 1, not an invalid 0), which means they survive zero-initialization without breaking their guarantee.
Every strong type in this library implements the full set of equality and comparison interfaces, so you can drop them into dictionaries, sorted collections, LINQ OrderBy, and equality checks without writing any boilerplate:
IEquatable<T>and the==/!=operatorsIComparable<T>,IComparable, and the<,<=,>,>=operatorsGetHashCodeandEquals(object?)overrides consistent with value-based equality- A sensible
ToString()that returns the underlying value
All strong types ship with System.Text.Json converters attached via [JsonConverter], so JsonSerializer.Serialize(value) and JsonSerializer.Deserialize<T>(...) just work — the wire format is the underlying primitive ("hello", 42, etc.), not an object with a Value property. Invalid input during deserialization surfaces as a JsonException at the boundary, which is where you want it.
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 friends to their underlying column types. See the package readme for setup details.
Extension members on any enum type give you cached metadata, factories, and flag helpers without the ceremony of calling Enum.Parse, Enum.GetValues, or writing your own caches. 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 gives you just the single-bit members (so None = 0 and composite values are excluded), and AllFlagsCombined OR-s them together — perfect for seeding an "everything on" value at runtime without having to remember to update a SuperAdmin = Reader | Writer | Admin literal every time you add a flag.
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.
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 ArgumentExceptionAsEnum<TEnum> / ToEnum<TEnum> are plain extensions on string? that sidestep a C# limitation: because Roles.TryParse(...) is an extension member on the enum type, it can't be called through an open generic TEnum parameter. These close the gap so you can parse an enum whose type you only know generically.
Warning
The types in this section are inherited from FuncSharp and will be removed in a future release. They are kept for now so existing code keeps compiling, but new code should avoid them.
An Option<A> represents a value that may or may not be available. In modern C# with nullable reference types enabled, T? already covers this case at the language level, so Option<A> has become redundant.
Warning
Option<A> will be replaced by a modern Maybe<T> implementation that supports pattern matching and integrates cleanly with nullable reference types.
Try<A, E> represents the result of an operation that can end in either success (A) or error (E), making the failure path explicit in the type signature instead of hiding it behind exceptions.
Warning
Try<A, E> will be replaced by a modern Result<T, E> implementation that supports pattern matching.
Coproduct[N]<T1, …, TN> is a sum type (tagged union) representing exactly one of N alternatives. Useful for modelling "either-or" outcomes where an abstract class hierarchy would be too loose.
Warning
Coproduct will be replaced by a more modern OneOf implementation with first-class pattern matching support.
This library is the continuation of FuncSharp by Honza Siroky, bringing the concepts into modern C#. Licensed under the MIT License.