Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
abc73f5
Add TypeConverter, IFormattable and span parse/format to strong types
KaliCZ Jul 14, 2026
8a26429
Document config binding and formatting in the skill
KaliCZ Jul 14, 2026
729b1c6
Remove the StrongTypes.Wpf package (breaking)
KaliCZ Jul 14, 2026
88a4c6d
Pin the host culture in the WPF culture tests
KaliCZ Jul 15, 2026
b723592
Cover both binding paths, and pin the null/empty matrix
KaliCZ Jul 15, 2026
77eb4f3
Pin what an unconfigured strong type actually holds
KaliCZ Jul 15, 2026
c5ddd5b
Add Kalicz.StrongTypes.Configuration with BindStrongTypes()
KaliCZ Jul 15, 2026
230c1e8
Add ST0004 nudging strong-typed options onto BindStrongTypes
KaliCZ Jul 15, 2026
54941f5
Require every property BindStrongTypes can read intent for, not just …
KaliCZ Jul 15, 2026
46c2d35
Test the presence check against sections that have children, not a value
KaliCZ Jul 15, 2026
286ae56
Check for nulls, not for configuration keys
KaliCZ Jul 15, 2026
ce35833
Finish the docs sweep for BindStrongTypes
KaliCZ Jul 15, 2026
d9a972a
Stop implying the missing-key hole is a wrapper problem
KaliCZ Jul 15, 2026
02b3793
Check every depth, not just the top level
KaliCZ Jul 15, 2026
bdd5d05
Take ST0004 to the same depth as the check it recommends
KaliCZ Jul 15, 2026
36b4a52
Cut the comments in this PR down to what earns its place
KaliCZ Jul 16, 2026
f7865ae
Merge branch 'main' into claude/affectionate-lumiere-12792b
KaliCZ Jul 16, 2026
69db45e
Offer the Bind -> BindStrongTypes fix even without the package
KaliCZ Jul 16, 2026
ce4528d
Test the WPF culture path across a host x binding matrix
KaliCZ Jul 16, 2026
c744df1
Tighten the readme configuration and WPF sections per review
KaliCZ Jul 16, 2026
2f212a9
Answer "why a separate package" in the Configuration readme
KaliCZ Jul 16, 2026
3fe6929
Drive the culture tests from an explicit case table with negatives
KaliCZ Jul 16, 2026
822b936
Stop implying BindStrongTypes lets an explicit null through
KaliCZ Jul 16, 2026
6349a49
Add the wrong-separator culture cases to the write-back table
KaliCZ Jul 16, 2026
2b23556
Simplify the configuration binding readme section
KaliCZ Jul 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@ as part of the same PR.
spec, move the long form into the type's XML docs and leave a tight
example in the readme.
- **Package readmes** — each package that ships its own readme
([`StrongTypes.EfCore`](src/StrongTypes.EfCore/readme.md),
([`StrongTypes.Configuration`](src/StrongTypes.Configuration/readme.md),
[`StrongTypes.EfCore`](src/StrongTypes.EfCore/readme.md),
[`StrongTypes.FsCheck`](src/StrongTypes.FsCheck/readme.md),
[`StrongTypes.OpenApi.Microsoft`](src/StrongTypes.OpenApi.Microsoft/readme.md),
[`StrongTypes.OpenApi.Swashbuckle`](src/StrongTypes.OpenApi.Swashbuckle/readme.md),
[`StrongTypes.Wpf`](src/StrongTypes.Wpf/readme.md),
[`StrongTypes.AspNetCore`](src/StrongTypes.AspNetCore/readme.md))
should reflect what the package actually does after your change. Update
the one that matches.
Expand Down
11 changes: 7 additions & 4 deletions Skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ 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. Every
wrapper ships a `System.Text.Json` converter, so invalid input fails at
deserialization before any endpoint code runs.
deserialization before any endpoint code runs, and 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.
Expand All @@ -19,19 +20,20 @@ demand when about to write code against that surface.
| 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.Wpf` | `TypeConverter` infrastructure for two-way MVVM binding to strong-typed view-model properties. One `this.UseStrongTypes()` call in `App.OnStartup`. |
| `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 (every wrapper carries 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** — only for WPF apps that two-way bind to strong-typed VM properties. See `references/wpf.md`.
- **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/wpf.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
Expand Down Expand Up @@ -62,12 +64,13 @@ demand when about to write code against that surface.
| 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: `this.UseStrongTypes()` in `App.OnStartup` for two-way MVVM binding | `references/wpf.md` |
| WPF / WinForms two-way MVVM binding — zero setup, `ValidatesOnExceptions=True`, culture, nullable properties | `references/wpf.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
Expand Down
203 changes: 203 additions & 0 deletions Skill/references/configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# Configuration and options binding

Strong types bind from `IConfiguration` / `IOptions<T>` with **no setup** —
every wrapper carries a `TypeConverter`, which is what `ConfigurationBinder`
uses to turn a config string into a typed value. Put the wrapper straight on
the options class:

```csharp
public sealed class RetryOptions
{
public Positive<int> MaxRetries { get; set; }
public NonNegative<int> InitialDelaySeconds { get; set; }
public NonEmptyString? Name { get; set; }
public Email? Contact { get; set; }
public Positive<int>? OptionalLimit { get; set; } // absent key stays null
}

builder.Services.AddOptions<RetryOptions>()
.Bind(builder.Configuration.GetSection("Retry"))
.ValidateOnStart(); // see "Fail at startup" below
```

```json
{
"Retry": {
"MaxRetries": 5,
"InitialDelaySeconds": 0,
"Name": "checkout",
"Contact": "ops@example.com"
}
}
```

## The invariant is the validation rule

This is the reason to use a wrapper here rather than a plain `int`. The type
already says "must be positive", so a bad value is rejected without a
`[Range]` attribute or a custom `IValidateOptions<T>`:

```
InvalidOperationException: Failed to convert configuration value '-5' at 'Retry:MaxRetries'
to type 'StrongTypes.Positive`1[System.Int32]'.
---> ArgumentException: Value must be positive, but was '-5'. (Parameter 'value')
```

The outer frame names the **config path** and the **offending value**; the
inner one names the **broken invariant**. Don't add a `[Range(1, int.MaxValue)]`
on top of a `Positive<int>` — it is the same rule stated twice, and only one
of the two is enforced by the type.

## Fail at startup, not on the first request

Options binding is **lazy**. Without `ValidateOnStart()`, a bad value does not
stop the app: it starts, serves traffic, and throws on the first request that
reads `IOptions<T>.Value` — surfacing as a 500 rather than a failed deploy.

```csharp
builder.Services.AddOptions<RetryOptions>()
.Bind(builder.Configuration.GetSection("Retry"))
.ValidateOnStart(); // conversion failures now abort startup
```

`ValidateOnStart()` forces eager binding, so it catches conversion failures
even with **no** `IValidateOptions<T>` registered. Always add it when options
hold strong types — otherwise the invariant buys you a later crash, not an
earlier one.

## The missing key — `BindStrongTypes()`

Add [`Kalicz.StrongTypes.Configuration`](https://www.nuget.org/packages/Kalicz.StrongTypes.Configuration/)
and the declaration becomes the spec — no `[Required]`, no `ValidateDataAnnotations()`:

```csharp
builder.Services.AddOptions<ClientOptions>()
.BindStrongTypes(builder.Configuration.GetSection("Client"))
.ValidateOnStart();
```

It fails when a property **declared non-nullable is null after binding** — the one state an absent
key can leave that the declaration forbids. Your nullable annotations already say which properties
those are, so no `[Required]` has to repeat it:

```csharp
public sealed class ClientOptions
{
public NonEmptyString Name { get; set; } = null!; // fails unless configured
public NonEmptyString? Nickname { get; set; } // nullable — fine
public string ApiKey { get; set; } = null!; // fails unless configured
public string Endpoint { get; set; } = "https://x.test"; // has a default — never null
public Positive<int> MaxRetries { get; set; } // never checked — see below
}
```

```
OptionsValidationException: 'Client:Name' is null. Configure it, give ClientOptions.Name a default,
or declare it nullable.
```

**Every reference property is covered, not only the wrappers** — a non-nullable `string` is as
broken by a missing key as a `NonEmptyString`. A declared default needs nothing: it isn't null.

**At every depth, not just the top level.** Nested options classes, collection elements and
dictionary values are all walked, and the failure names the full path (`'Client:Endpoints:1:Host'`).
The walk stops exactly where `ConfigurationBinder` stops — at a type it has a `TypeConverter` for —
so a wrapper is a leaf, never something to recurse into.

**Value types are not checked, and don't need to be.** An unconfigured `Positive<int>` is `1` and an
unconfigured `bool` is `false` — defaults, and values those types are happy to hold. There is no
contradiction to catch, so this will not tell you that you forgot `MaxRetries`. Declare it
`Positive<int>?` if "not configured" must be distinguishable; that is the only way the CLR offers.

An options class in an assembly with `<Nullable>disable</Nullable>` declares no intent, so nothing
on it is enforced.

Analyzer **ST0004** flags a plain `Bind` / `Configure` that would leave a non-nullable wrapper null,
with a code fix that rewrites the call. It reports only this library's own wrappers, never a plain
`string`, so it sees less than the check it points you at. It also stays quiet for a property
already carrying `[Required]`, which genuinely covers that case.

The rest of this section describes what happens **without** that package.

## `ValidateOnStart()` does not catch a *missing* value

It only surfaces failures that binding itself raises. A key that simply isn't
there raises nothing — binding succeeds, it just doesn't assign — so the
property keeps whatever the options class gave it. Since a real options class
has no initialisers, that means:

| declaration | key not in config | is that a problem? |
| --------------------------- | ----------------- | ----------------------------------------- |
| `NonEmptyString Name` | **`null`** | **yes** — the type says it is never null |
| `Positive<int> MaxRetries` | `1` (default) | no — a value the type is happy to hold |
| `Positive<int>? MaxRetries` | `null` | no — it is nullable |

**A non-nullable `NonEmptyString` can be null.** The invariant constrains every
value the type can hold; it cannot make the binder assign one. Against an
unconfigured key the wrapper is no better than `string`, and that is the only
place an absent key produces a state the declaration forbids.

Guard it with `[Required]` and `ValidateDataAnnotations()` — or with
`BindStrongTypes()` above, which reads the same intent from the nullable
annotation instead of an attribute:

```csharp
builder.Services.AddOptions<RetryOptions>()
.Bind(builder.Configuration.GetSection("Retry"))
.ValidateDataAnnotations() // [Required] → catches a null reference property
.ValidateOnStart(); // → catches an invalid value, at startup
```

Neither can help with a struct: `default(Positive<int>)` is `1`, so nothing
distinguishes "configured as 1" from "never configured". Declare it
`Positive<int>?` when that distinction matters — the CLR offers no other way
to say it.

## `null` and `""` — the exact matrix

Not uniform, and not guessable. Measured; `ConfigurationBinder.Get<T>` and
`IOptions<T>.Value` agree on **every** row:

| in `appsettings.json` | `NonEmptyString` | `NonEmptyString?` | `Positive<int>` | `Positive<int>?` |
| --------------------- | ---------------- | ----------------- | --------------- | ---------------- |
| key absent | **`null`** † | `null` | `1` (`default`) | `null` |
| `null` | **`null`** | `null` | `1` (`default`) | `null` |
| `""` | **throws** | **throws** | **throws** | **`null`** |
| `" "` | **throws** | **throws** | throws (format) | throws (format) |
| valid | binds | binds | binds | binds |
| invariant breach | throws | throws | throws | throws |

† unless the options class initialises the property, which is rare — an absent
key leaves whatever was already there, and for a reference type that is `null`.

Three things in there surprise people:

- **`""` is not uniform.** It throws for everything *except* a nullable **struct**
wrapper, where it binds to `null` — a nullable struct resolves to the BCL's
`NullableConverter`, which maps empty to null before our converter is consulted.
A nullable *reference* wrapper gets no such treatment (`NonEmptyString?` is the
same runtime type as `NonEmptyString`), so `""` reaches `Parse` and fails.
Don't reach for `""` to mean "unset" — omit the key.
- **An explicit `null` nulls even a non-nullable reference property.** Nullability
is erased by the time the binder runs, so `"Name": null` leaves a
`NonEmptyString Name` holding `null`, exactly as it would a `string` — which is
precisely what `BindStrongTypes` (above) catches, treating it the same as an
absent key. Without that package, omit the key rather than writing `null`.
- **An explicit `null` overwrites, an absent key does not.** `"Name": null` clears
a property initialised in the options class; leaving the key out keeps it.

## Things worth knowing

- **A missing key leaves the default**, it does not throw. `default(Positive<int>)`
is `1` and `default(NonNegative<int>)` is `0` — both satisfy their invariant, so
a typo'd key is a silently valid default. Use `Positive<int>?` when "not
configured" must be distinguishable from a configured value.
- **`ConfigurationBinder` parses with the invariant culture**, so `"1234.5"` is
a `Positive<decimal>` of 1234.5 regardless of the host's locale. Config files
are not localized; don't write `1234,5`.
- **Nullable wrappers still enforce the invariant** when a value is present:
`OptionalLimit` may be absent, but `-1` is rejected.
- The same `TypeConverter` powers anything that goes through `TypeDescriptor` —
WPF/WinForms two-way binding (`references/wpf.md`), designers,
`PropertyGrid`, and libraries doing generic string↔object conversion. It is
one mechanism on the type, so none of them need a registration call.
45 changes: 45 additions & 0 deletions Skill/references/numeric.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,53 @@ All four types have `AsX` / `ToX` extension methods available on any
and matching `==`, `!=`, `<`, `<=`, `>`, `>=` operators on both sides —
so `4.ToPositive() > 2` is just a comparison, no unwrap.
- `GetHashCode`, `Equals(object?)`, `ToString()` delegating to the value.
- `IFormattable` / `ISpanFormattable` — format specifiers and cultures reach
the underlying value (see "Formatting" below).
- `IParsable<Self>` / `ISpanParsable<Self>` — `Parse` / `TryParse` from a
`string` or a `ReadOnlySpan<char>`, enforcing the invariant on the way in.
This is also what lets ASP.NET Core bind a wrapper from `[FromQuery]` /
`[FromRoute]` without any package.
- `System.Text.Json` converter via `[JsonConverter]` — serialises as the
underlying primitive.
- `TypeConverter` via `[TypeConverter]` — binds from `appsettings.json` /
`IOptions<T>`. See `references/configuration.md`.

## Formatting

Format specifiers and format providers pass straight through to the
underlying value, so a wrapper formats exactly like the number it wraps:

```csharp
var price = 1234.5m.ToPositive();

$"{price:N2}" // "1,234.50"
$"{price:C}" // "$1,234.50"
string.Format(germanCulture, "{0}", price) // "1234,5"
price.ToString("N2", CultureInfo.InvariantCulture);
```

Note `price.ToString()` with no provider uses the **current culture**, same
as `decimal.ToString()`. Pass an explicit provider anywhere the output is
machine-read (logs, URLs, files) rather than displayed.

Parsing accepts a span, so a wrapper can be read out of a larger buffer
without slicing it into a string first:

```csharp
Positive<int>.Parse(line.AsSpan(6, 2), CultureInfo.InvariantCulture);
Positive<int>.TryParse(span, CultureInfo.InvariantCulture, out var count);
```

Both interfaces also let a wrapper satisfy generic constraints, which no
amount of reaching for `.Value` at the call site can do for the caller:

```csharp
static T Read<T>(ReadOnlySpan<char> s) where T : ISpanParsable<T> => T.Parse(s, null);
static string Render<T>(T v, string f) where T : IFormattable => v.ToString(f, null);

Read<Positive<int>>("42");
Render(price, "C");
```

## Arithmetic

Expand Down
Loading