Skip to content

feat: generate the arithmetic operators a plain value object declares - #188

Merged
CaffeinatedCoder merged 4 commits into
release/2.4.0from
feat/value-object-arithmetic
Oct 31, 2026
Merged

CaffeinatedCoder merged 4 commits into
release/2.4.0from
feat/value-object-arithmetic

Conversation

@CaffeinatedCoder

@CaffeinatedCoder CaffeinatedCoder commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Closes #184

Problem

A plain value object over a number couldn't be added, subtracted or scaled without unwrapping it: Money.From(a.Value + b.Value). You could declare IAdditionOperators<Money, Money, Money>, but then you had to write the operator yourself. The generators already write the equality, comparison and IMinMaxValue members next to it. EricksonLopez.DomainPrimitives generates arithmetic on every numeric primitive (KB-055).

Change

  • Opt in by declaring the interface. A plain value object that declares IAdditionOperators, ISubtractionOperators, IMultiplyOperators, IDivisionOperators, IModulusOperators, IUnaryNegationOperators or IUnaryPlusOperators (with itself as the first type argument, directly or through another interface) gets those operators and their checked forms. The wrapped type must implement the same interface with the value object replaced by itself. Another operand or result type passes through: IMultiplyOperators<Money, decimal, Money> scales, and IDivisionOperators<Money, Money, decimal> is a ratio. TSelf * TSelf is allowed when declared.
  • The wrapped type's operator. Each operator calls GeneratedArithmetic (new, [EditorBrowsable(Never)], in the base package). It is a call through a type parameter constrained to the interface, in a stated unchecked/checked context. Results, overflow and the checked context are therefore the wrapped type's. This includes byte (a plain left.Value + right.Value would add as int) and char (explicit operators). No reflection, so it is Native AOT safe.
  • Never on a validated value object, and never where the wrapped type doesn't match. DateTime, TimeSpan, DateOnly and Guid implement none of the operator interfaces. The interface then fails with CS0535, as before.
  • A hand-written operator is a seam, not CMTK1011. That covers one for the same operands, or an explicit implementation of the interface, its own or a base record's. It is kept with its checked form, so 2.3 code builds unchanged. A lone hand-written checked operator keeps failing (CS9025) rather than being completed. Of two interfaces asking for one operator, the second fails with CS0738.
  • ValueObjectArithmeticAspect (+ templates), AspectOrder, ValueObjectAspect, and ValueObjectDeclaration.ImplementsExplicitly.
  • Docs: docs/value-objects.md gains "Add, subtract and scale" (compiled sample with checked outputs), the Generators README gets a table row and the seam paragraph, plus the changelog. Decision record 0122 refines 28. design/aspects.md and design/packages.md are updated.
  • Oracles: 10 claims (C28-*), ArithmeticParityTests registered as a differential oracle with the seed arithmetic-checked-forms-not-generated, and an axes.md row.

Tests

  • ArithmeticParityTests is a differential oracle over 22 declarations: every numeric wrapped type from sbyte to BigInteger, char, Half, float, double and decimal, a record class over int and double, a nested one and an internal one. They are declared through an interface of the tests' own. Each runs against the wrapped type's own operators, unchecked and checked, on bounds, zero, one, minus one and the floating-point specials.
  • ArithmeticTests covers the concrete-type calls, the other operand forms, the checked context, the byte width and a record class's null operand. It also covers a hand-written + (kept, with its checked form, - still generated), an explicit implementation, a base record's explicit implementation, and a validated value object with its own +.
  • ArithmeticBuildTests covers CS0535 (validated, string, DateTime), CS0738 (two interfaces for one operator) and CS9025 (a lone checked +), with no LAMA error.
  • ArithmeticQueryTests (EF Core, SQLite): an operator between two value objects translates (ef_add) and keeps the rows memory keeps. One with another operand throws, pinned and filed as EF Core: translate an arithmetic operator whose other operand or result is not the value object #186. A hand-written operator would run as the database's arithmetic: EF Core: a hand-written arithmetic operator in a query runs as the database's arithmetic #187.
  • The smoke test's Native AOT consumer prints arithmetic=7.0 42 OverflowException with no trim or AOT warning (whole smoke test passed).
  • GeneratedSurface.verified.txt: additions only.
  • Verify the guard, each seed applied alone with the arithmetic classes run:
    • Checked forms not generated: 20 tests fail.
    • A checked + generated beside a hand-written +: A_hand_written_operator_is_kept_with_its_checked_form fails.
    • Explicit implementations not kept: 2 tests fail.
    • Operators on a validated value object: ArithmeticBuildTests fails.
    • A lone hand-written checked + completed: 2 tests fail.
    • % not generated: the build fails with CS0535 naming the operator.
  • Generators.Tests 1140/1140, Tests 1125/1125, Samples 151/151, Conventions 2027/2027, Oracles.Tests 51/51.

Weaving review

  1. Reach. The aspect is added to AspectOrder and to the outbound list. Missing from either, no operator is generated and the declared interface fails with CS0535, which is loud, not silent. Covered sites: directly declared, through another interface, nested, internal, record class, across an assembly (ProbeMoney), and transitively (the smoke test's layered consumers).
  2. Entry points. Operators exist only on plain value objects, which have no rules, so wrapping through the private constructor skips nothing. The seed proves validated value objects get none.
  3. Discovery. The operator interfaces are matched by their symbols, and the helper is looked up among GeneratedArithmetic's own members.
  4. EF Core. Measured on SQLite and filed: EF Core: translate an arithmetic operator whose other operand or result is not the value object #186, EF Core: a hand-written arithmetic operator in a query runs as the database's arithmetic #187.
  5. OpenAPI and JSON. Unaffected: no schema or converter change.
  6. Packaging. CodoMetis.TypeKit stays Metalama-free (GeneratedArithmetic uses System.Numerics only). Native AOT is measured by the smoke test.

Review fix (5b0723a)

An outside review found that an interface's explicit implementation of an operator was replaced: IAddsLeft<TSelf>'s +, which returns the left operand, gave way to a generated public one, and generic addition read 3 where the interface's answered 1. The preservation check now reads the interfaces the value object implements too, its base records' included. ArithmeticTests.An_interfaces_explicit_implementation_is_kept covers a generic interface, one two interfaces down, a base record's interface and a single value object's (beside which - is still generated); reverting the check fails it with 3 for 1. Generators 1145/1145, Conventions 2027/2027. The same gap in members generated before 2.4 (IMinMaxValue, IFormattable) is #191.

🤖 Generated with Claude Code

A plain value object that declares IAdditionOperators, ISubtractionOperators,
IMultiplyOperators, IDivisionOperators, IModulusOperators, IUnaryNegationOperators or
IUnaryPlusOperators, with itself as the first type argument, gets those operators and their
checked forms, where the wrapped type implements the same interface with the value object
replaced by itself. `Money * 3m` and `Money / Money` as a decimal ratio work the same way: an
operand or result of another type passes through.

Each operator calls the wrapped type's through GeneratedArithmetic, a new
[EditorBrowsable(Never)] helper in the base package: a call through a type parameter
constrained to the interface, unchecked or checked as stated. Written as
`left.Value + right.Value`, two bytes were added as ints and char's explicit operators were out
of reach. No reflection, so Native AOT needs nothing; the smoke test's aot consumer uses one
directly, through generic code and in a checked context.

Never by default and never on a validated value object: the interface then stays
unimplemented and fails with CS0535, as before. A hand-written operator for the same operands,
or an explicit implementation of the interface, its own or a base record's, is kept with its
checked form (decision 121, refining 28), so 2.3 code builds unchanged and nothing new is an
Error. A hand-written checked operator alone keeps failing with CS9025.

Input class: the declaration's shape (GTS), over the generated-type axes. The differential
oracle ArithmeticParityTests runs 22 declarations (sbyte to BigInteger, char, Half, float,
double, decimal, a record class over int and double, a nested and an internal one) against
the wrapped type's own operators, unchecked and checked, on bounds, zero, one, minus one and
the floating-point specials. Seeded: no checked forms (20 tests fail), a checked + beside a
hand-written + (1), an explicit implementation not kept (2), operators on a validated value
object (1), a lone hand-written checked + completed (2), % not generated (CS0535).

EF Core translates an operator between two value objects as the wrapped type's arithmetic
(ef_add on SQLite); one with another operand throws (#186), and a hand-written one would run
as the database's arithmetic (#187).

Closes #184

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… is kept

An interface can implement a generic-math operator explicitly, and every
type that declares it reaches that implementation until it declares a
public operator of its own. The arithmetic aspect looked for one on the
value object and its base records only, so it generated a public `+`
beside `IAddsLeft<TSelf>`'s, which returns the left operand: generic
addition read 3 where the interface's answered 1, and code that compiled
on 2.3 changed behaviour on 2.4 (found in an outside review of #188).

The preservation check now reads the interfaces the value object
implements too, its base records' included, and keeps that operator with
its checked form, as it keeps the type's own.

Input class: where an existing implementation of the operator comes
from. ArithmeticTests now covers a generic interface over TSelf, one two
interfaces down, an interface of a base record, and an interface for a
single value object, beside which `-` is still generated. Reverting the
check fails the test with 3 for 1.

Measured on the way, outside this change: the generated IMinMaxValue
and IFormattable members replace an interface's explicit implementation
the same way; that predates 2.4 and is filed on its own.

Defect-Class: GTS
Found-By: OR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2.3.1 takes 0121 for #191's record (epic #198), since 2.3.1 stops at 0120 and DesignRecordTests
wants the numbers without a gap. The arithmetic record becomes 0122: its file and heading, its
index row, design/aspects.md's link and the aspect's doc comment.

Until 2.3.1's 0121 reaches release/2.4.0, The_records_are_numbered_without_a_gap fails on this
branch, the only conventions test that does.

Refs #184

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CaffeinatedCoder added a commit that referenced this pull request Oct 30, 2026
2.3.1 takes decision 0121 (#198), and the 2.4.0 records move up one:
the arithmetic record is 0122 (#188), the hand-written-operator record
0123 (#189), and this one 0124. Its heading and index row move, its
citations of 121 and 122 become 122 and 123, the arithmetic record's
index row names it as refining, and design/ef-core.md links it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g 2.3.1

release/2.4.0 took main after v2.3.1 (#264). Four conflicts, each two
additions at one place:

- design/adr/README.md: 0121 (2.3.1) before 0122 (arithmetic).
- Generators README, "Write your own members": 2.3.1's paragraph on seams
  in a base record follows the seams it names, then the arithmetic one.
- ValueObjectDeclaration: ImplementsExplicitly beside 2.3.1's Elsewhere,
  IsPublic, PublicName and InheritedSeams; none is in the merge base.
- GeneratedSurface.verified.txt: regenerated by GeneratedSurfaceTests; it
  is the merge base plus both sides' changes, entry for entry (7623).

Generators.Tests 1448/1448, Conventions.Tests 2034/2034.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CaffeinatedCoder
CaffeinatedCoder merged commit ded5fa6 into release/2.4.0 Oct 31, 2026
10 checks passed
@CaffeinatedCoder
CaffeinatedCoder deleted the feat/value-object-arithmetic branch October 31, 2026 17:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/value-objects Value-object contracts and what the aspects generate for them enhancement New feature or request package/generators Affects CodoMetis.TypeKit.Generators

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant