| name | strongtypes |
| description | Write 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>. |
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 plain string) sits on an options class. Binding works without it (the scalar wrappers carry a TypeConverter); 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]. See references/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.md covers both pipelines.
- WPF / WinForms — no package. Two-way binding works off the core package's
[TypeConverter]s; there is nothing to install or call. A Kalicz.StrongTypes.Wpf package existed before v2 — if you find it referenced, remove it. See references/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 once AddStrongTypes() is called — opt out with AddStrongTypes(o => o.NormalizeJsonErrorKeys = false), or set o.JsonErrorKeyCasing. The binder alone is niche — [FromBody] already round-trips NonEmptyEnumerable<T> via the core JSON converters — but the error-key normalization applies to any JSON API. See references/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: an OrderUpdate DTO's Price property. Null means "don't touch
the price". You can't "remove" a price, so there are only two states,
and decimal? 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. null skips, Maybe<T>.None clears, Maybe.Some(x) sets.
HTTP PATCH is 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? from
TryCreate / As…. Caller unwraps with is not { } v:
public IActionResult CreateUser(string? nameInput)
{
if (nameInput.AsNonEmpty() is not { } name)
return BadRequest("name must not be empty");
return Ok(_service.Create(name));
}
No Result is 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>. TError is 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
ToX extensions (ToNonEmpty, ToPositive, …), which throw
ArgumentException. Don't invent a Result<Order, Exception> to
smuggle an exception through.
Summary rule
T? is the default. Reach for Maybe<T>? only when the field
genuinely has three states (skip / clear / set). Reach for
Result<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 {} for None),
and the interval types serialise as { "Start": …, "End": … } (both
endpoint keys always present; an unbounded endpoint is null; the
StartInclusive / EndInclusive bound flags appear only when false).
- Invalid payloads throw
JsonException at 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". Reserve Maybe<T>? for the three-state case.
-
Maybe<T>? for an update field that can't be cleared — decimal? Price is correct; Maybe<decimal>? Price invents a meaningless
"clear" state.
-
Result<T, E> for single-reason validations — return T? from
the parser, let the caller turn null into a 400.
-
Spelling out explicit factories — return value; over
Result<T, TError>.Success(value); Maybe<int> x = 42; over
Maybe<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's Positive<int>.
The signature is the contract; let it carry the invariant.
public void Greet(string name) { ... }
public void Greet(NonEmptyString name) { ... }
.Value and 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.
ToX is for internal code where invalid input is a bug. AsX is
for external input where invalid means "reply with a 400". And
prefer the extensions (input.ToNonEmpty(), input.AsNonEmpty())
over the static NonEmptyString.Create / TryCreate — the
extensions read better at the call site and chain naturally.
var name = request.Name.ToNonEmpty();
var name = NonEmptyString.Create(request.Name);
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.
public record Article(string Title, NonEmptyEnumerable<string> Tags);
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().
db.Users.Where(u => u.Name.StartsWith("ali"))
db.Users.Where(u => u.Name.Unwrap().StartsWith("ali"))