Imported from KaliCZ/StrongTypes (
Skill/SKILL.md). Install upstream withnpx skills add KaliCZ/StrongTypes --skill Skill. Copyright stays with the author.
StrongTypes — skill
Library of focused C# value wrappers (NonEmptyString, Positive<T>,
NonEmptyEnumerable<T>, …) and algebraic types (Maybe<T>,
Result<T, TError>) that push invariants into the type system. The
wrappers ship System.Text.Json converters (Digit and Result<T, TError>
are the exceptions), so invalid input fails at deserialization before any
endpoint code runs, and the scalar wrappers (NonEmptyString, Email,
Digit, the numerics) carry a TypeConverter, so the same invariant
validates appsettings.json as it binds.
Per-type detail lives in references/*.md — load the relevant file on
demand when about to write code against that surface.
Packages
| Package | What it gives you |
|---|---|
Kalicz.StrongTypes |
The core types (NonEmptyString, numeric wrappers, NonEmptyEnumerable<T>, Maybe<T>, Result<T, TError>, …). |
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. Reads intent from nullable annotations, so no [Required] per property. |
Kalicz.StrongTypes.EfCore |
EF Core value converters, interval column mapping (two endpoint columns by default, JSON opt-in), and LINQ translators (.Unwrap(), interval Start/End) so strong types sit directly on entity properties. |
Kalicz.StrongTypes.FsCheck |
FsCheck Arbitrary<T> generators registered via [Properties(Arbitrary = new[] { typeof(Generators) })]. |
Kalicz.StrongTypes.OpenApi.Microsoft |
Schema transformers for Microsoft.AspNetCore.OpenApi (AddOpenApi()) so wrappers render as the wire JSON shape, not the CLR shape. |
Kalicz.StrongTypes.OpenApi.Swashbuckle |
The same idea for Swashbuckle's AddSwaggerGen() pipeline — schema filters that produce the wire JSON shape. |
Kalicz.StrongTypes.AspNetCore |
MVC model binder for NonEmptyEnumerable<T> from [FromForm] / [FromQuery] / [FromHeader] / [FromRoute], plus opt-out normalization of JSON request-body validation error keys ($.value → Value) so they match data-annotation / model-binding keys. The binder is niche; [FromBody] round-trips wrappers via the core JSON converters without it. |
Add packages only when the host project actually hits that stack:
- Configuration — when a non-nullable reference property (
NonEmptyString,Email, or a plainstring) sits on an options class. Binding works without it (the scalar wrappers carry aTypeConverter); the package only stops an absent key leaving that property null. Skip it if every reference property is nullable or has a default, or if you already use[Required]. Seereferences/configuration.md. - EfCore — only if EF Core is in use.
- FsCheck — only for property-based test projects.
- OpenApi.Microsoft vs OpenApi.Swashbuckle — pick one, matching the spec generator the app already wires up. They are not interchangeable.
references/openapi.mdcovers both pipelines. - WPF / WinForms — no package. Two-way binding works off the core package's
[TypeConverter]s; there is nothing to install or call. AKalicz.StrongTypes.Wpfpackage existed before v2 — if you find it referenced, remove it. Seereferences/desktop.md. - AspNetCore — add it when a controller takes
NonEmptyEnumerable<T>from a non-body source (forms, repeated query params, header lists), or when you want JSON request-body validation errors keyed by the property name (Value) instead of the System.Text.Json path ($.value). The error-key normalization is on by default onceAddStrongTypes()is called — opt out withAddStrongTypes(o => o.NormalizeJsonErrorKeys = false), or seto.JsonErrorKeyCasing. The binder alone is niche —[FromBody]already round-tripsNonEmptyEnumerable<T>via the core JSON converters — but the error-key normalization applies to any JSON API. Seereferences/aspnetcore.md.
Type catalog — what's in the box
Quick scan of what the library ships. Per-type detail (factories, full API surface, edge cases) lives in the linked reference — load it on demand when about to write code against that surface.
Validated wrappers (invalid input → null from TryCreate / AsX, exception from Create / ToX)
| Type | Invariant | Reference |
|---|---|---|
NonEmptyString |
non-null, non-empty, non-whitespace | references/nonemptystring.md |
Email |
a valid e-mail address, ≤ 254 chars | references/email.md |
Positive<T> / NonNegative<T> / Negative<T> / NonPositive<T> |
sign constraint on any INumber<T> |
references/numeric.md |
NonEmptyEnumerable<T> / INonEmptyEnumerable<T> |
at least one element | references/nonemptyenumerable.md |
Digit |
a single decimal digit, 0–9 |
references/parsing.md |
FiniteInterval<T> / Interval<T> / IntervalFrom<T> / IntervalUntil<T> |
ordered endpoints, Start <= End, bounds inclusive by default with per-bound startInclusive/endInclusive opt-out; the variant fixes which endpoints are bounded |
references/intervals.md |
Algebraic types (no validation; carry a value or an alternative)
| Type | Shape | Reference |
|---|---|---|
Maybe<T> |
Some(T) / None. Maybe<T>? = three-state. |
references/maybe.md |
Result<T, TError> / Result<T> |
Success(T) / Error(TError). Result<T> is a shorthand for TError = Exception. |
references/result.md |
Helpers and integrations
| Topic | Reference |
|---|---|
Enum extensions (Enum.Parse, AllValues, AllFlagValues, GetFlags) and string? parsers (AsInt, AsGuid, AsEnum<T>, …) |
references/parsing.md |
Configuration / IOptions<T> binding — zero setup, invariant doubles as validation, ValidateOnStart(), and BindStrongTypes() (Kalicz.StrongTypes.Configuration) for the absent key |
references/configuration.md |
T?.Map, bool.MapTrue / MapFalse |
references/map.md |
IEnumerable<T> extensions, ReadOnlyList, Result partition helpers |
references/collections.md |
EF Core: UseStrongTypes value converters, interval column mapping, .Unwrap() LINQ marker |
references/efcore.md |
FsCheck: shared Generators class, shipped arbitraries |
references/fscheck.md |
OpenAPI: AddStrongTypes() for either AddOpenApi() (Kalicz.StrongTypes.OpenApi.Microsoft) or AddSwaggerGen() (Kalicz.StrongTypes.OpenApi.Swashbuckle) |
references/openapi.md |
WPF / WinForms two-way MVVM binding — zero setup, ValidatesOnExceptions=True, culture, nullable properties |
references/desktop.md |
ASP.NET Core MVC: services.AddStrongTypes() for NonEmptyEnumerable<T> from [FromForm] & friends, plus JSON request-body validation error-key normalization |
references/aspnetcore.md |
Design philosophy — picking the right wrapper
Most misuses of StrongTypes come from reaching for Maybe<T> or
Result<T, TError> when a plain T? would do. Read the decision trees
before writing a new DTO field or service signature.
Decision tree for "optional / nullable" fields
-
Can the field be absent, but never be explicitly cleared? → use
T?. Example: anOrderUpdateDTO'sPriceproperty. Null means "don't touch the price". You can't "remove" a price, so there are only two states, anddecimal?captures them both. -
Can the field be absent and be explicitly cleared to null? → use
Maybe<T>?. The pattern applies to any update operation that needs those three states — DTO, service signature, message payload, builder parameter — wherever "don't touch", "clear", and "set" all need to coexist.nullskips,Maybe<T>.Noneclears,Maybe.Some(x)sets. HTTPPATCHis the most visible case (JSON distinguishes "property omitted" from "property sent as null"); the same shape applies to any in-process update method. -
Is the field always present and meaningful? → the bare strong type (
NonEmptyString,Positive<int>, …). No nullable wrapping.
public record OrderUpdate(
decimal? Price, // optional update; cannot be cleared
Maybe<string>? Nickname, // three-state: skip / clear / set
NonEmptyString OrderCode // always required
);
Decision tree for validation / parsing results
-
Is this a single-reason validation where the caller turns the failure straight into an HTTP 400 or an exception? → return
T?fromTryCreate/As…. Caller unwraps withis not { } v:public IActionResult CreateUser(string? nameInput) { if (nameInput.AsNonEmpty() is not { } name) return BadRequest("name must not be empty"); // 'name' is NonEmptyString from here on — the whole service // call tree now has a typed, validated name. return Ok(_service.Create(name)); }No
Resultis needed because the caller already knows why the parse failed — the rule is encoded in the wrapper's name. -
Does the caller need to distinguish between multiple failure reasons, translate them to user-facing codes, or aggregate them with other failures? → return
Result<T, TError>.TErroris normally an enum, occasionally a string if you don't need localisation.public enum OrderError { PaymentFailed, OutOfStock, InvalidAddress } public Result<Order, OrderError> CreateOrder(OrderData data) { ... } -
Does an exception fit the failure better? → use the throwing
ToXextensions (ToNonEmpty,ToPositive, …), which throwArgumentException. Don't invent aResult<Order, Exception>to smuggle an exception through.
Summary rule
T?is the default. Reach forMaybe<T>?only when the field genuinely has three states (skip / clear / set). Reach forResult<T, TError>only when the caller needs the error reason, not just the fact of failure — typically because there are multiple reasons to distinguish or aggregate. Everything else is a primitive or a strong wrapper.
Result flow in practice
Services return Result<T, TError>. Controllers consume those results
and turn them into HTTP responses, but rarely construct a Result —
controller-level validation just returns BadRequest(...) directly.
[HttpPost]
public async Task<IActionResult> Create(CreateRequest request)
{
if (request.Name.AsNonEmpty() is not { } name)
return BadRequest("name required");
Result<Order, OrderError> result = await _orders.Create(name, request.Items);
if (result.Error is { } e)
return Problem(MapError(e));
return Created("...", result.Success);
}
Implicit operators — Maybe and Result
Maybe<T> and Result<T, TError> accept a plain value (or error)
through implicit operators. Prefer return value; over the explicit
factories — Maybe<int> m = 42; instead of Maybe<int>.Some(42),
return value; / return error; instead of Result.Success<T, TError>(value)
/ Result.Error<T, TError>(error). Detail and edge cases in
references/maybe.md and references/result.md. Reach for the explicit
factories only when type inference can't pick a branch (typically when
T == TError).
(NonEmptyString and the numeric wrappers expose only a wrapper →
underlying implicit conversion — the reverse is explicit because not
every string/int passes the invariant. Construct them through the
AsX / ToX extensions (input.AsNonEmpty(), value.ToPositive()) —
prefer those over the static Create / TryCreate factories. See
anti-pattern #5 for keeping the wrapped type flowing through your code
instead of unwrapping eagerly.)
JSON — zero setup
Every wrapper except Result<T, TError> and Digit carries [JsonConverter(...)].
Consequences:
- No
JsonSerializerOptions.Converters.Add(...)calls. It just works. - On-the-wire format matches the underlying primitive:
"hello",42,[1, 2, 3]. Two exceptions:Maybe<T>serialises as{ "Value": x }/{ "Value": null }(or accepts{}forNone), and the interval types serialise as{ "Start": …, "End": … }(both endpoint keys always present; an unbounded endpoint isnull; theStartInclusive/EndInclusivebound flags appear only whenfalse). - Invalid payloads throw
JsonExceptionat deserialization — in ASP.NET Core that's before your endpoint runs. Result<T, TError>has no converter by design. Translate to a response DTO before serialising.
Anti-patterns — common misuses to avoid
The first four restate the philosophy above; the remaining five flag mistakes you wouldn't catch from the decision trees alone.
-
Maybe<T>for a plain optional field —T?already captures "might be absent". ReserveMaybe<T>?for the three-state case. -
Maybe<T>?for an update field that can't be cleared —decimal? Priceis correct;Maybe<decimal>? Priceinvents a meaningless "clear" state. -
Result<T, E>for single-reason validations — returnT?from the parser, let the caller turn null into a 400. -
Spelling out explicit factories —
return value;overResult<T, TError>.Success(value);Maybe<int> x = 42;overMaybe<int>.Some(42). Use the explicit form only when inference collides. -
Signatures typed against what the value can be, not what it must be. Pick parameter, return, and property types from the value's actual semantics — not from the caller's convenience or whatever primitive happens to be lying around. If a name genuinely can't be empty for the function to make sense, the parameter is
NonEmptyString. If a count must be positive, it'sPositive<int>. The signature is the contract; let it carry the invariant.// Wrong — `Greet("")` is meaningless, but the signature allows it. public void Greet(string name) { ... } // Right — the type rules out the meaningless call. public void Greet(NonEmptyString name) { ... }.Valueand the implicit conversion are interchangeable — neither is "wrong". The fix is to model the right type at the boundary, not to police how you cross into BCL / third-party APIs that take primitives. -
Constructing wrappers through the throwing factory in a controller.
ToXis for internal code where invalid input is a bug.AsXis for external input where invalid means "reply with a 400". And prefer the extensions (input.ToNonEmpty(),input.AsNonEmpty()) over the staticNonEmptyString.Create/TryCreate— the extensions read better at the call site and chain naturally.// Wrong — throws into ASP.NET's exception pipeline for user input. var name = request.Name.ToNonEmpty(); // Wrong — verbose static factory for what `input.ToNonEmpty()` does. var name = NonEmptyString.Create(request.Name); // Right — extension + nullable form for user input. if (request.Name.AsNonEmpty() is not { } name) return BadRequest("name required"); -
Writing your own JSON converter for a wrapper. Don't. Every wrapper already ships one. A custom converter is either a bug (no validation) or a config issue elsewhere.
-
NonEmptyEnumerable<T>for "probably not empty, usually". Use it only where "zero elements" really is an error (batch recipients, decomposed paths). Otherwise every caller pays a.ToNonEmpty()tax.// Wrong — tags is naturally allowed to be empty. public record Article(string Title, NonEmptyEnumerable<string> Tags); // Right — an empty tag list is valid. public record Article(string Title, IReadOnlyList<string> Tags); -
Forgetting
.Unwrap()in EF LINQ. Equality / ordering / null checks on the wrapper translate. Anything using the underlying type's operators (Contains, arithmetic,EF.Functions.*) needs.Unwrap().// Doesn't translate — EF can't call string.StartsWith on a NonEmptyString. db.Users.Where(u => u.Name.StartsWith("ali")) // Right — .Unwrap() rewrites to a bare column reference for SQL. db.Users.Where(u => u.Name.Unwrap().StartsWith("ali"))