Skip to content

KaliCZ/StrongTypes

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

25 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

StrongTypes - Stronger Typing for C#

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.

Build License NuGet Version NuGet Downloads

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.

Contents

Strong value types

NonEmptyString

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.

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(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.

What you get for free

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 == / != operators
  • IComparable<T>, IComparable, and the <, <=, >, >= operators
  • GetHashCode and Equals(object?) overrides consistent with value-based equality
  • A sensible ToString() that returns the underlying value

JSON serialization

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.

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 friends to their underlying column types. See the package readme for setup details.

Parsing helpers

Enums

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.

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> 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.

Legacy types (to be replaced)

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.

Option<A>

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>

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

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.

Acknowledgments

This library is the continuation of FuncSharp by Honza Siroky, bringing the concepts into modern C#. Licensed under the MIT License.

About

A copy of Funcsharp repository

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages