Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
52 changes: 9 additions & 43 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,48 +27,19 @@ feature's folder:
- `src/StrongTypes/Strings/NonEmptyString.cs`
- `src/StrongTypes/Strings/NonEmptyStringExtensions.cs`
- `src/StrongTypes/Strings/NonEmptyStringJsonConverter.cs`
- `src/StrongTypes/Try/TryExtensions.cs`
- `src/StrongTypes/Maybe/MaybeExtensions.cs`

Extensions that *produce* a feature type go into that feature's folder
even when the input type is from somewhere else (e.g. `ToTry` on `T?`
lives under `Try/`, because the result — not the receiver — is what
even when the input type is from somewhere else (e.g. `ToMaybe` on `T?`
lives under `Maybe/`, because the result — not the receiver — is what
defines the slice).

Namespaces stay flat at `StrongTypes` regardless of folder nesting. Folder
structure is for humans; the namespace is one flat shelf. The same applies
to the test project — keep tests in `namespace StrongTypes.Tests` even
when they live in subfolders, to avoid shadowing type names (e.g., a
nested `StrongTypes.Tests.Try` namespace would hide the `StrongTypes.Try`
class from sibling files).

## Migration from _Old

The codebase is mid-migration. Anything under a folder or filename suffixed with
`_Old` is legacy and should not be modified or extended. New code goes in
non-suffixed folders (e.g., new string types go in `src/StrongTypes/Strings/`).

When you rework an _Old type, treat the rewrite as a real review — drop the
`_Old` suffix, read the entire file, and improve anything that is wrong or
outdated, not just what the task names. Do not port code verbatim.

**Dealing with the _Old file being replaced:**

- If the old file is *entirely* about the type being reworked (e.g.,
`NonEmptyString_Old.cs`), delete it. The new file is the replacement.
- If the old file mixes multiple concerns (e.g., `StringExtensions_Old.cs`
contains extensions for both `string` and `NonEmptyString`), leave the
`_Old` file in place and create a new non-suffixed file containing *only*
the reworked pieces. The remaining legacy surface stays where it was until
its own migration pass.
- In either case, minimally patch remaining `_Old` call sites only as much as
is needed to keep the build green.

**Other rules:**

- Do **not** take a dependency on other _Old types (e.g., `Option<T>`). Prefer
modern BCL primitives (nullable reference types, `Result`-shaped APIs, etc.).
- Do not delete _Old files unless explicitly asked or unless the rule above
applies.
nested `StrongTypes.Tests.Maybe` namespace would hide the
`StrongTypes.Maybe<T>` type from sibling files).

## Validated types — the TryCreate / Create pattern

Expand All @@ -87,14 +58,13 @@ public static NonEmptyString Create(string? value)
Rules:
- The validation logic lives in `TryCreate` only. `Create` delegates.
- Constructors are `private` — callers must go through the factories.
- Use nullable reference types. The library project does not enable them
globally yet, so add `#nullable enable` at the top of each new file.
- Do **not** return `Option<T>` from new code.
- Use nullable reference types.

## Tests

All testing rules — unit, API integration, OpenAPI integration, and
analyzer tests — live in [`testing.md`](testing.md). **Read that file
All testing rules — unit, API / ASP.NET Core / OpenAPI integration,
configuration binding, WPF, and analyzer tests — live in
[`testing.md`](testing.md). **Read that file
before writing or modifying a test, and before writing any code that
will need tests** (a new strong type, a converter, an analyzer, an API
endpoint, …). It is the single source of truth; do not infer testing
Expand Down Expand Up @@ -158,10 +128,6 @@ that a host may opt into skipping it when the amd64-only image won't start
[`testing.md`](testing.md). Absent the opt-in, a SQL Server that fails to
start is always a hard failure, never a silent skip.

Current state: uses plain `string` / `string?`. Strong-type converters
(EF Core value converters, JSON converters) will be wired in once the
parallel work on those lands.

## Comments — XML and `//`

**XML comments** (`/// <summary>`, `<param>`, `<remarks>`, …) are for
Expand Down
19 changes: 11 additions & 8 deletions Skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ description: Write idiomatic Kalicz.StrongTypes code (NonEmptyString, numeric wr

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, and a `TypeConverter`, so
the same invariant validates `appsettings.json` as it binds.
`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.
Expand All @@ -29,7 +31,7 @@ demand when about to write code against that surface.

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`.
- **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.
Expand All @@ -42,14 +44,15 @@ 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 `AsX`, exception from `ToX`)
**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 `'0'`–`'9'` character | `references/parsing.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)
Expand Down Expand Up @@ -194,7 +197,7 @@ instead of unwrapping eagerly.)

## JSON — zero setup

Every wrapper except `Result<T, TError>` carries `[JsonConverter(...)]`.
Every wrapper except `Result<T, TError>` and `Digit` carries `[JsonConverter(...)]`.
Consequences:

- No `JsonSerializerOptions.Converters.Add(...)` calls. It just works.
Expand Down
7 changes: 2 additions & 5 deletions Skill/references/collections.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ library.
- `Concat(params T[] items)` and `Concat(params IEnumerable<T>[] others)`
— flatten a few extras into a sequence without `.Concat(new[] { ... })`:
```csharp
var all = existing.Concat(1, 2, 3);
var all = existing.Concat(list1, list2, list3);
NonEmptyEnumerable<int> all = existing.Concat(1, 2, 3);
NonEmptyEnumerable<int> all = existing.Concat(list1, list2, list3);
```

- `Flatten()` on `IEnumerable<IEnumerable<T>>` — an alias for
Expand Down Expand Up @@ -79,9 +79,6 @@ Exception? agg = list.Aggregate(); // IReadOnlyList<Except
Exception agg = nonEmptyList.Aggregate(); // INonEmptyEnumerable<Exception>
```

This is what `Result.Aggregate(...)` uses under the hood when `TError`
is `Exception`.

## Boolean helper

```csharp
Expand Down
4 changes: 2 additions & 2 deletions Skill/references/configuration.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Configuration and options binding

Strong types bind from `IConfiguration` / `IOptions<T>` with **no setup** —
every wrapper carries a `TypeConverter`, which is what `ConfigurationBinder`
every scalar 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:

Expand Down Expand Up @@ -113,7 +113,7 @@ An options class in an assembly with `<Nullable>disable</Nullable>` declares no
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
with a code fix that rewrites a `Bind` call (a flagged `Configure` gets the diagnostic only). 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.

Expand Down
2 changes: 1 addition & 1 deletion Skill/references/efcore.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Equality, null checks, ordering, and grouping work directly on the
wrapper:

```csharp
var needle = "alice".ToNonEmpty();
NonEmptyString needle = "alice".ToNonEmpty();
var user = await db.Users.SingleOrDefaultAsync(u => u.Name == needle);

var withNickname = await db.Users.Where(u => u.Nickname != null).ToListAsync();
Expand Down
48 changes: 48 additions & 0 deletions Skill/references/email.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# `Email`

A validated e-mail address wrapping `System.Net.Mail.MailAddress`, capped at
254 characters (the RFC 5321 deliverable limit, `Email.MaxLength`). A sealed
reference type; equality is case-insensitive on the full address.

## Construction

```csharp
Email? maybe = Email.TryCreate(input); // null when invalid, too long, or null
Email email = Email.Create(input); // throws ArgumentException when invalid

// The string extensions produce the BCL MailAddress instead:
MailAddress? parsed = input.AsEmail(); // null when invalid
MailAddress strict = input.ToEmail(); // throws when invalid
```

`MailAddress` converts to `Email` implicitly (`Email e = mailAddress;`), and
`Email` converts implicitly to both `string` (the address) and `MailAddress`,
so it passes straight into APIs that take either.

## What you get

- `Value` — the underlying `MailAddress` (use it for `.User` / `.Host`).
- `Address` — the address string; `ToString()` returns the same.
- Case-insensitive equality (`==` / `!=`) against `Email`, `MailAddress`,
`string`, and `NonEmptyString`, and `CompareTo` for ordering.
- `IParsable<Email>`, a JSON converter, and a `TypeConverter`, so JSON
bodies, `IConfiguration` binding, and WPF/WinForms two-way binding all
work with no setup.

## JSON

Serialises as a plain JSON string (the address). An invalid or too-long
string throws `JsonException`; JSON `null` yields a null reference even for
a non-nullable declaration — nullability is compile-time only.

## Integrations

- **EF Core** — maps to a string column via `Kalicz.StrongTypes.EfCore`;
plain `MailAddress` properties are converted too (`references/efcore.md`).
- **OpenAPI** — both adapters render
`{ "type": "string", "format": "email", "minLength": 1, "maxLength": 254 }`
(`references/openapi.md`).
- **Options binding** — a non-nullable `Email` left null by a missing key is
caught by `BindStrongTypes()` (`references/configuration.md`).
- **FsCheck** — `Generators` ships `Email` / `NullableEmail` / `MaybeEmail`
arbitraries (`references/fscheck.md`).
12 changes: 6 additions & 6 deletions Skill/references/intervals.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ to `true`: `IntervalFrom.Create(1, null, endInclusive: false)` equals
- Implicit widening to the less-constrained variants (see below).

```csharp
var interval = Interval.Create(start, end);
Interval<int> interval = Interval.Create(start, end);

string label = interval switch
{
Expand Down Expand Up @@ -160,16 +160,16 @@ keeps the endpoints the receiver already guarantees — intersecting with a
`Interval<T>?`.

```csharp
var booked = FiniteInterval.Create(10, 20);
var query = IntervalFrom.Create(15, null);
FiniteInterval<int> booked = FiniteInterval.Create(10, 20);
IntervalFrom<int> query = IntervalFrom.Create(15, null);

bool clash = booked.Overlaps(query); // true
FiniteInterval<int>? shared = booked.GetOverlap(query); // [15, 20]
booked.GetOverlap(FiniteInterval.Create(30, 40)); // null — disjoint

FiniteInterval.Create(0, 5).GetOverlap(FiniteInterval.Create(5, 9)); // [5, 5] — inclusive bounds touch
var morning = FiniteInterval.Create(9, 12, endInclusive: false);
var afternoon = FiniteInterval.Create(12, 17, endInclusive: false);
FiniteInterval<int> morning = FiniteInterval.Create(9, 12, endInclusive: false);
FiniteInterval<int> afternoon = FiniteInterval.Create(12, 17, endInclusive: false);
morning.Overlaps(afternoon); // false — [9, 12) stops short of noon
```

Expand All @@ -192,7 +192,7 @@ a `FiniteInterval<DateTime>` (half-open, `[midnight, next midnight)`), and
`DateTime.ToDateOnly()` drops a moment's time of day.

```csharp
var stay = FiniteInterval.Create(new DateTime(2026, 7, 1, 14, 0, 0), new DateTime(2026, 7, 4, 10, 0, 0));
FiniteInterval<DateTime> stay = FiniteInterval.Create(new DateTime(2026, 7, 1, 14, 0, 0), new DateTime(2026, 7, 4, 10, 0, 0));

stay.Contains(new DateOnly(2026, 7, 4)); // true — the stay reaches into that day
stay.ToDateInterval(); // FiniteInterval<DateOnly> [2026-07-01, 2026-07-04]
Expand Down
2 changes: 1 addition & 1 deletion Skill/references/map.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ string? upper = maybeText.Map(s => s.ToUpperInvariant());
int? parsed = input.Map(s => int.TryParse(s, out var n) ? n : (int?)null);

// Async
int? await = await maybeId.MapAsync(id => _store.CountAsync(id));
int? counted = await maybeId.MapAsync(id => _store.CountAsync(id));
```

Overloads cover the four combinations of value-type / reference-type input
Expand Down
2 changes: 1 addition & 1 deletion Skill/references/maybe.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Maybe<User> m = users.SafeSingle(u => u.Id == target);
Maybe<User> m = users.SafeLast();
Maybe<int> hi = scores.SafeMax();
Maybe<int> lo = scores.SafeMin();
Maybe<Score> worst = results.SafeMin(r => r.Value);
Maybe<int> lowest = results.SafeMin(r => r.Score); // min of the projection

// Drop Nones in one shot.
IEnumerable<int> values = maybes.Values();
Expand Down
8 changes: 4 additions & 4 deletions Skill/references/nonemptyenumerable.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ These operations keep the non-empty guarantee and return

- `Select(...)`, including the indexed overload.
- `SelectMany(...)` *when the inner result is itself non-empty*.
- `Distinct()`, `Distinct(IEqualityComparer<T>)`.
- `Concat(params T[])`, `Concat(IEnumerable<T>)`, both on
- `Distinct()`.
- `Concat(params ReadOnlySpan<T>)`, `Concat(IEnumerable<T>)`, both on
`NonEmptyEnumerable<T>` and `INonEmptyEnumerable<T>`.
- `Flatten()` on `INonEmptyEnumerable<INonEmptyEnumerable<T>>`.
- `T head.Concat(params IEnumerable<T>[] tails)` — builds a
Expand Down Expand Up @@ -74,8 +74,8 @@ T maxBy = list.MaxBy(x => x.Key);
T minBy = list.MinBy(x => x.Key);
```

All of these have overloads on both `NonEmptyEnumerable<T>` and
`INonEmptyEnumerable<T>`, so chains on either receiver type work.
All of these accept both `NonEmptyEnumerable<T>` and
`INonEmptyEnumerable<T>` as the receiver, so chains on either type work.

## Covariance

Expand Down
11 changes: 6 additions & 5 deletions Skill/references/nonemptystring.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ plain `string` because a substring could be empty.
- `StartsWith(...)`, `EndsWith(...)` overloads mirroring `string`.
- Implicit conversion to `string` — you can pass a `NonEmptyString` to any
`string` parameter without `.Value`.
- Full equality / comparison operators against `NonEmptyString` *and*
`string` — no `.Value` needed for `==`, `!=`, `<`, `<=`, `>`, `>=`.
- `==` / `!=` against both `NonEmptyString` and `string`, `<` / `<=` / `>` /
`>=` against `NonEmptyString`, and `CompareTo(string)` — all without `.Value`.

## `Unwrap()` — the EF-Core marker

Expand Down Expand Up @@ -111,6 +111,7 @@ public record User(NonEmptyString Name, NonEmptyString? Nickname);
## JSON

`[JsonConverter(typeof(NonEmptyStringJsonConverter))]` is attached on the
type. Serialises as a plain JSON string. Deserialising `""`, a
whitespace-only string, or `null` (into the non-nullable form) throws
`JsonException`. No `JsonSerializerOptions` registration required.
type. Serialises as a plain JSON string. Deserialising `""` or a
whitespace-only string throws `JsonException`; JSON `null` yields a null
reference even for the non-nullable declaration — nullability is
compile-time only. No `JsonSerializerOptions` registration required.
4 changes: 2 additions & 2 deletions Skill/references/numeric.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,8 @@ the implicit conversion (or `.Value` / `.Unwrap()`) and re-wrap explicitly
when you need the invariant back:

```csharp
Positive<int> a = 3;
Positive<int> b = 5;
Positive<int> a = 3.ToPositive();
Positive<int> b = 5.ToPositive();

int sum = a + b; // implicit → int
Positive<int> wrapped = sum.ToPositive(); // re-wrap; throws if invariant broke
Expand Down
4 changes: 2 additions & 2 deletions Skill/references/openapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@ In 3.0, `exclusiveMinimum` / `exclusiveMaximum` emit as booleans paired
with `minimum` / `maximum`; in 3.1 they emit as numeric values. Both
forms are produced correctly — the version setting is the only knob.

Swashbuckle 10.x emits 3.0 by default; switch via its own options
(`SwaggerGeneratorOptions.OpenApiVersion`) if you need 3.1.
Swashbuckle 10.x emits 3.0 by default; switch via the middleware options
(`app.UseSwagger(o => o.OpenApiVersion = …)`) if you need 3.1.

## UI

Expand Down
7 changes: 0 additions & 7 deletions Skill/references/parsing.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,6 @@ foreach (var flag in (Roles.Reader | Roles.Admin).GetFlags()) { ... }
`InvalidOperationException` if the enum is not `[Flags]` — so a typo at
declaration fails on first use, not silently.

There's also a typed converter pair for generic code:

```csharp
long raw = EnumExtensions<Roles>.ToLong(value);
Roles back = EnumExtensions<Roles>.FromLong(raw);
```

## String parsers

The open-generic enum variant (useful when you only know the enum type
Expand Down
2 changes: 1 addition & 1 deletion Skill/references/result.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Result<NonEmptyString, string> a = name.AsNonEmpty().ToResult("name required");
Result<NonEmptyString, string> b = name.AsNonEmpty().ToResult(() => BuildMessage());

// Result<T> (Exception-flavoured)
Result<NonEmptyString> c = name.AsNonEmpty().ToResult(); // throws default Exception on null
Result<NonEmptyString> c = name.AsNonEmpty().ToResult(); // null → error branch holding an ArgumentNullException
Result<NonEmptyString> d = name.AsNonEmpty().ToResult(new ArgumentException()); // custom exception
Result<NonEmptyString> e = name.AsNonEmpty().ToResult(() => new Ex("...")); // factory
```
Expand Down
Loading