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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ section is the GitHub release's notes.

## 2.4.0 — Unreleased

### Added

- Generators: a plain value object that declares `IAdditionOperators`, `ISubtractionOperators`, `IMultiplyOperators`, `IDivisionOperators`, `IModulusOperators`, `IUnaryNegationOperators` or `IUnaryPlusOperators` gets their operators, `Money + Money` or `Money * 3m`, each the underlying type's own, overflow and `checked` context included; nothing without the interface, nothing on a validated value object, and an operator you wrote is kept ([Add, subtract and scale](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/value-objects.md#add-subtract-and-scale)).

### Changed

- Packages: the public surface is checked against 2.3.1's, so nothing 2.3.1 ships is removed or changed in 2.4.0.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 0122. Arithmetic operators are opt-in by their interfaces, and a hand-written one is a seam

Date: 2026-10-30
Status: accepted

Context: [#184](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/184), under the 2.4.0 epic
[#177](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/177); [Aspects](../aspects.md); refines
[decision 28](0028-seams-declared-everything-else-refused.md) under [decision 34](0034-errors-fixed-within-major-version-warnings.md).
EricksonLopez.DomainPrimitives generates arithmetic on every numeric primitive
([KB-055](../knowledge/KB-055-ericksonlopez-domainprimitives.md)); TypeKit wrote `Money.From(a.Value + b.Value)`.

**A plain value object gets the arithmetic operators of the operator interfaces it declares, and nothing
else.** `ValueObjectArithmeticAspect` reads `IAdditionOperators`, `ISubtractionOperators`,
`IMultiplyOperators`, `IDivisionOperators`, `IModulusOperators`, `IUnaryNegationOperators` and
`IUnaryPlusOperators` among the interfaces the value object implements, directly or through another
interface, whose first type argument is the value object. It generates an interface's operator, and its
`checked` form where the interface has one, when the wrapped type implements the same interface with the
value object replaced by the wrapped type throughout: `IMultiplyOperators<Money, decimal, Money>` needs
`IMultiplyOperators<decimal, decimal, decimal>`, `IDivisionOperators<Money, Money, decimal>` (a ratio) needs
the same. An operand or a result of another type passes through as it is.

- **Opt-in, never by default, never by attribute.** Declaring the interface is what a generic-math constraint
reads anyway, and the operators a value object has are visible on its declaration. `TSelf * TSelf` is
allowed when declared: whether two amounts multiply is the domain's question, not TypeKit's.
- **The wrapped type's operator, through `GeneratedArithmetic`** in the base package: a call through a type
parameter constrained to the interface, unchecked or checked, as `GeneratedFormatting` calls formatting.
Written as `left.Value + right.Value`, two `byte`s were added as `int`s, and `char`, whose operator
interfaces are explicit, could not be reached at all. Each helper states its context (`unchecked(...)`,
`checked(...)`), so the consumer's `CheckForOverflowUnderflow` changes nothing: the context picks the
operator, as it picks the wrapped type's, and the operator does what the wrapped type's does in it. No
reflection, so Native AOT needs nothing (the smoke test's `aot` consumer calls one directly, through
generic code and in a checked context).
- **Never on a validated value object.** An operator cannot return a `Result`, and a sum outside the range
`Create` accepts would have to throw or skip `Create`. The declared interface stays unimplemented and the
build fails with CS0535, as it did before. So does an interface the wrapped type does not match:
`DateTime`, `TimeSpan`, `DateOnly` and `Guid` implement none of the operator interfaces (measured in the
.NET 10 reference assemblies: they were dropped before .NET 7 shipped).
- **A hand-written operator is a seam.** One the value object declares for the same operands, of either kind,
whatever it returns, or an explicit implementation of the interface, its own, a base record's or an interface's, is kept,
and neither form of that operator is generated; the operators not written are. Decision 28 would make it
CMTK1011, but code that implemented these interfaces by hand on 2.3 must build unchanged on 2.4, and
decision 34 lets no minor report more under an Error. Nothing generated depends on an arithmetic operator,
so a kept one disagrees with nothing, which is what a seam needs. Its `checked` form goes with it: a
generated `checked +` beside a hand-written `+` did the wrapped type's arithmetic in a checked context and
the user's everywhere else. The other way round, a hand-written `checked +` without its `+` is CS9025, and
generating the `+` would have built it, adding by the wrapped type's rules outside a checked context and
the user's inside one; it is kept failing. A public operator generated beside a base record's explicit implementation
would not implement the interface, which the base does: generic code would reach the base's, `a + b`
ours. One beside an interface's explicit implementation, the `+` of an `IMoneyMath<TSelf>` that every type
declaring it reaches by default, replaced it: generic addition read 3 where the interface's answered 1 (an
outside review of #188).
- **One operator per signature.** C# allows one `operator /(Money, Money)`, so of two interfaces that ask for
it, the first declared gets it and the second fails the build (CS0738), to be implemented explicitly by hand.
- **A record class's operand of its own type** is checked for `null` and throws `ArgumentNullException`
naming it, where dereferencing it threw `NullReferenceException` inside generated code.
- **In an EF Core query** an operator between two value objects is translated as the wrapped type's
arithmetic over the columns and the database computes it (`ef_add` on SQLite, measured); EF never runs
the operator. One with another operand or result throws there, since EF applies the value object's
converter to the other side ([#186](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/186)),
and a hand-written operator would run as the database's arithmetic, not as written
([#187](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/187)).
- **Left out:** `++` and `--`, the bitwise and shift operators, and `T * Money` (C# cannot declare it on
`Money`, whose interface's first type argument must be itself).

`ArithmeticParityTests` holds it as a differential oracle: 22 declarations (every numeric wrapped type from
`sbyte` to `BigInteger`, `char`, `Half`, `float`, `double`, `decimal`, both record shapes, nested and
internal) against the wrapped type's own operators, unchecked and checked, over a catalogue of bounds, zero,
one, minus one and the floating-point specials. `ArithmeticTests` holds the seam and the other operand forms,
`ArithmeticBuildTests` the CS0535, CS0738 and CS9025 cases, and `ArithmeticQueryTests` the EF Core translation.
1 change: 1 addition & 0 deletions design/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,4 @@ per area ([design](../plan.md)) link to the records they rest on.
| [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | A form an Error misses, found before its version is tagged, is reported in that version when it loses what the rule protects in every build, though code built on the previous minor can contain it: 2.3.0's CMTK0003 on a `Task.WhenAny` of results (#159), `Wait()` on a branch (#160), an array in `Task.WaitAll`/`WaitAny` (#161), a dropped `Task.WhenEach` (#162), and a `WhenAny` of held tasks silent; the rule stops at the tag, after which decision 34 holds; refines 34 and 118 | 2026-10-29 | accepted |
| [0120](0120-a-net11-0-target-and-documented-floor-raises-join-a-minor.md) | A `net11.0` target joins a 2.x minor when a feature needs it, and a dependency floor may rise in a minor, on every target or on `net11.0` alone, if it is documented (the Semantic Versioning FAQ calls a dependency update with an unchanged public API compatible; a project that references the dependency lower gets NU1605, measured on SDK 11 RC1, so the changelog names the floor, who it reaches and both ways out, and the smoke test shows it); a union new on `net11.0` is an addition and may join a 2.x minor (a `LangVersion` 14 consumer who matches on it gets CS9327, and on Metalama 2026.1 CS8652, measured); Metalama is one version for every target and TypeKit follows the maintained Metalama year in a minor: 2.3 and 2.4 stay on 2026.1 and 2.5.0 moves both targets to 2027.0 once stable (a direct 2026.1 reference gets NU1605 and stays on 2.4.x; a woven `net11.0` project goes from C# 14 to 15; conditions: a license-free CI and clean-container build, the ceiling and the 2.4.x line documented); the SDK 11 spike comes first (#170); 3.0.0 stays the `net10.0` drop and a change of an existing contract, timed to .NET 12 LTS, and .NET 11 is no reason for a major; refines decision 44 | 2026-10-29 | accepted |
| [0121](0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md) | CMTK1011, and CMTK1008 for comparison, refuse a generated member written by hand wherever a value object or closed set reaches it: on it, in a base record, public or explicit, or as an interface's explicit implementation, in any assembly, found by the interface mapping since Metalama lists no private member of another assembly's type; a base record's `Create` beside the value object's own (#195) and a seam a base record declares (#194) too; `IConvertible`, `IValueObject<,>`, `IValueObjectMaterializer<,>`, `IPlainValueObject<,>` and a set's `IParsable` declared through an interface or by a base record are CMTK1011 where the aspect crashed (#192); a closed set's base class counts as a base record does, its `sealed` `ToString()` the seam and one not sealed refused (#205); an interface's `==` and .NET's own `INumberBase` bridges are not refused; an exception to 34 taken in the patch 2.3.1 (#191, #192, #194, #195, #197, #205) | 2026-10-30 | accepted |
| [0122](0122-arithmetic-operators-are-opt-in-and-a-hand-written-one-is-a-seam.md) | A plain value object gets the arithmetic operators (`+`, `-`, `*`, `/`, `%`, unary `-` and `+`, and their `checked` forms) of the operator interfaces it declares, where the wrapped type implements the same interface with the value object replaced by itself; each calls the wrapped type's through `GeneratedArithmetic`, a constrained generic call with its context stated; never by default and never on a validated value object, which keeps CS0535; a hand-written operator for the same operands, or an explicit implementation of the interface, its own or a base record's, is a seam, kept with its `checked` form, so 2.3 code builds unchanged; refines decision 28 | 2026-10-30 | accepted |
12 changes: 10 additions & 2 deletions design/aspects.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Aspects

- **Aspect-class count.** Eleven classes: ten for value objects and `ClosedSetAspect`, whose design is
- **Aspect-class count.** Twelve classes: eleven for value objects and `ClosedSetAspect`, whose design is
its own note ([Closed sets](closed-sets.md)). The Metalama Open Source edition (MIT) has no aspect-class
limit (pricing page, 2026-09-26). The unlicensed CI build confirms it (phase 6).
- **Entry points.** `ValueObjectAspect` introduces three private helpers on every value
Expand All @@ -9,7 +9,15 @@
`__TryFromText` (`false`). A plain value object constructs directly; a validated one calls
`Create` through `GeneratedFactories` in the base package. The exception is the explicit
`IValueObjectMaterializer.Materialize`, by contract. `MinValue`/`MaxValue` are only generated for
a plain value object.
a plain value object, and so are arithmetic operators, which wrap their result through the private
constructor as `From` does.
- **Arithmetic is opt-in (decided 2026-10-30, [decision 122](adr/0122-arithmetic-operators-are-opt-in-and-a-hand-written-one-is-a-seam.md)).**
`ValueObjectArithmeticAspect` generates the operators of the operator interfaces a plain value object
declares, where the wrapped type implements the same interface with the value object replaced by itself,
each calling the wrapped type's through `GeneratedArithmetic`, a constrained generic call with its context
stated. A hand-written operator for the same operands, or an explicit implementation of the interface, its
own, a base record's or an interface's, is a seam: kept with its `checked` form, never CMTK1011, so code that wrote them on
2.3 builds unchanged.
- **Declarations that cannot be generated** are errors, so no type is left half-generated:
CMTK1000/1001/1002 (not `partial`, not a record, a struct not `readonly`), CMTK1003 (more than one
marker), CMTK1004 (a validated marker whose first type argument is another type), CMTK1005 (a
Expand Down
4 changes: 2 additions & 2 deletions design/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

| Package | Depends on | Metalama | Contents |
|---|---|---|---|
| `CodoMetis.TypeKit` | Analyzers (flows, [Analyzers](analyzers.md)) | **no** | `Option<T>`, `Result<T,TError>`, `Result<TError>` and their extensions, all carrying `NotWireTypeJsonConverterFactory` ([Option and Result](option-result.md)); the value-object contracts `IValueObject<,>`, `IPlainValueObject<,>`, `IValue<T>`, `IValidatedValue<,,>`, `IValueObjectMaterializer<,>` ([EF Core](ef-core.md)); the closed-set contracts `IClosedSet<>`, `IFault`, `IFaultSet<>` and `FaultKind`; `Fault<TFault>` and `Fault.Of`, and `Fault<T1, T2>` and `Fault<T1, T2, T3>`, a fault of one of several types ([decision 78](adr/0078-fault-sets-compose-side-by-side.md)); `Patch<T>`, `PatchState` and `PatchJsonConverterFactory`; `Result.Catch`/`CatchAsync`; the text guards `AsText()`/`AsIdentifier()` (`StringTextExtensions`) and `TextFault`; the type-argument constraint attributes `[Abstract]`, `[Concrete]`, `[Sealed]`, `[Record]`; `RequireCustomInitializationAttribute`, `RequireParameterlessConstructorAttribute` and `ConstructorAccessibility`; `GuidValueExtensions` (`OrderId.New()` from a version 7 Guid); and what only generated code and the satellites use ([Discovery](discovery.md), [Aspects](aspects.md), [Native AOT](native-aot.md)): `GeneratedValueObjectAttribute<,>`, `GeneratedClosedSetAttribute<>`, `IValueObjectVisitor<>`, `IClosedSetVisitor<>`, `TranslatedAsWrappedValueAttribute`, `HandWrittenOrderingAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedJson`, `GeneratedJsonPlan<>`, `GeneratedClosedSets`, `IStoredJsonConverterSource` |
| `CodoMetis.TypeKit` | Analyzers (flows, [Analyzers](analyzers.md)) | **no** | `Option<T>`, `Result<T,TError>`, `Result<TError>` and their extensions, all carrying `NotWireTypeJsonConverterFactory` ([Option and Result](option-result.md)); the value-object contracts `IValueObject<,>`, `IPlainValueObject<,>`, `IValue<T>`, `IValidatedValue<,,>`, `IValueObjectMaterializer<,>` ([EF Core](ef-core.md)); the closed-set contracts `IClosedSet<>`, `IFault`, `IFaultSet<>` and `FaultKind`; `Fault<TFault>` and `Fault.Of`, and `Fault<T1, T2>` and `Fault<T1, T2, T3>`, a fault of one of several types ([decision 78](adr/0078-fault-sets-compose-side-by-side.md)); `Patch<T>`, `PatchState` and `PatchJsonConverterFactory`; `Result.Catch`/`CatchAsync`; the text guards `AsText()`/`AsIdentifier()` (`StringTextExtensions`) and `TextFault`; the type-argument constraint attributes `[Abstract]`, `[Concrete]`, `[Sealed]`, `[Record]`; `RequireCustomInitializationAttribute`, `RequireParameterlessConstructorAttribute` and `ConstructorAccessibility`; `GuidValueExtensions` (`OrderId.New()` from a version 7 Guid); and what only generated code and the satellites use ([Discovery](discovery.md), [Aspects](aspects.md), [Native AOT](native-aot.md)): `GeneratedValueObjectAttribute<,>`, `GeneratedClosedSetAttribute<>`, `IValueObjectVisitor<>`, `IClosedSetVisitor<>`, `TranslatedAsWrappedValueAttribute`, `HandWrittenOrderingAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedArithmetic`, `GeneratedJson`, `GeneratedJsonPlan<>`, `GeneratedClosedSets`, `IStoredJsonConverterSource` |
| `CodoMetis.TypeKit.Analyzers` | — | no | Roslyn analyzers and code fixes ([Analyzers](analyzers.md)). Nobody references it directly: it reaches every consumer through the base package |
| `CodoMetis.TypeKit.Generators` | TypeKit, Metalama.Framework 2026.1.x (flows) | yes | Internal aspects (the value-object family and `ClosedSetAspect`), internal `TransitiveProjectFabric`, `AspectOrder`. Future generated type families join this package too: one fabric, one aspect order |
| `CodoMetis.TypeKit.EntityFrameworkCore` | TypeKit, EF Core Relational | no | Generic converter, convention, `.Value` / `GetValue` / `ValueOrNull` translators, `UseTypeKit()` |
Expand All @@ -25,7 +25,7 @@ objects from a referenced domain assembly never needs Metalama itself.
| `CodoMetis.TypeKit` (2.0.0) | `Fault`, `FaultRuleExtensions` and `FaultClosedSetRuleExtensions` (2.1), `Patch`, `PatchState`, `PatchJsonConverterFactory`, `StringTextExtensions`, `TextFault`, the constraint attributes, `RequireParameterlessConstructorAttribute`, `ConstructorAccessibility` |
| `CodoMetis.TypeKit.ClosedSets` | `IClosedSet<>`, `IFault`, `IFaultSet<>`, `FaultKind`: what a user declares a closed set or a fault set with |
| `CodoMetis.TypeKit.ValueObjects` | Every contract a user declares or constrains on: `IValue`, `IValidatedValue`, `IValueObject`, `IPlainValueObject`, `IValueObjectMaterializer`, and `StoredJsonConverterFactory` |
| `CodoMetis.TypeKit.CompilerServices` | What only generated code and the satellites touch, all `[EditorBrowsable(Never)]`: `GeneratedValueObjectAttribute`, `GeneratedClosedSetAttribute`, `IValueObjectVisitor`, `IClosedSetVisitor`, `TranslatedAsWrappedValueAttribute`, `HandWrittenOrderingAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedJson`, `GeneratedJsonPlan`, `GeneratedClosedSets`, `IStoredJsonConverterSource`. The name follows `System.Runtime.CompilerServices` |
| `CodoMetis.TypeKit.CompilerServices` | What only generated code and the satellites touch, all `[EditorBrowsable(Never)]`: `GeneratedValueObjectAttribute`, `GeneratedClosedSetAttribute`, `IValueObjectVisitor`, `IClosedSetVisitor`, `TranslatedAsWrappedValueAttribute`, `HandWrittenOrderingAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedArithmetic`, `GeneratedJson`, `GeneratedJsonPlan`, `GeneratedClosedSets`, `IStoredJsonConverterSource`. The name follows `System.Runtime.CompilerServices` |
| `CodoMetis.TypeKit.EntityFrameworkCore` | `ValueObjectConverter<,>`, `ClosedSetConverter<TSet>`; the plugins, internal |
| `CodoMetis.TypeKit.AspNetCore` | the schema transformers, internal; `FaultProblem<TFault>`, `FaultResponse`, `FaultResponseOptions`, `FaultStatusAttribute<TFault>`, `IFaultFeature` |
| `Microsoft.AspNetCore.Http` | `ToHttpResult()`, for a `Result` and for a closed set's fault (`TypeKitHttpResultExtensions`, `TypeKitClosedSetHttpResultExtensions`), beside ASP.NET's own `Results` ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) |
Expand Down
Loading
Loading