StrongTypes — skill
SkillFiles & storageWrite idiomatic Kalicz.StrongTypes code (NonEmptyString, numeric wrappers, NonEmptyEnumerable, Maybe<T>, Result<T, TError>, EF Core and FsCheck integrations). Invoke when editing C# files that import the StrongTypes namespace, when designing DTOs / service signatures in projects that use the Kalicz.StrongTypes NuGet package, or when deciding between plain T?, Maybe<T>, and Result<T, TError>.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the StrongTypes — skill skill
What this skill tells your AI
The instructions your AI receives, as published by kalicz/strongtypes in Skill/SKILL.md and read by ahel’s review.
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"))
Signals
- GitHub stars
- 27
- Last commit
- Jul 2026
Advanced
- Catalog kind
- skill
- Gateway key
strongtypes- Source
- github.com/kalicz/strongtypes