compiler-399.md
August 2, 2026 · View on GitHub
What happens
For every concrete type with exactly one field, the C# writer emits an implicit conversion in
both directions between the type and its field's type. Observed in freshly generated output
(--csharp-style=extensions --optimize --optimize-arrays --inline --loops --static-abstract):
// _Length.g.cs
public static implicit operator Number(Length self) => self.Meters;
public static implicit operator Length(Number value) => new Length(value);
public static implicit operator Length(Integer value) => new Length(value);
public static implicit operator Length(int value) => new Integer(value);
public static implicit operator Length(float value) => new Number(value);
public static implicit operator float(Length value) => value.Meters;
// _VertexIndex.g.cs
public static implicit operator Integer(VertexIndex self) => self.Value;
public static implicit operator VertexIndex(Integer value) => new VertexIndex(value);
// _Direction2D.g.cs
public static implicit operator Vector2D(Direction2D self) => self.Vector;
public static implicit operator Direction2D(Vector2D value) => new Direction2D(value);
Why it matters
Each of these erases an invariant the vocabulary spends effort establishing, and erases it silently, at every call site, with no cast in the source:
- Units. A
Lengthis freely afloatand afloatis freely aLength. Any API taking aLengthaccepts a bare number, and aLengthflows into anyfloatparameter. The quantity types stop carrying dimension the moment the code is C#. - Typed indices.
VertexIndexandIntegerare interchangeable, so a face index passed where a vertex index is expected compiles.CONVENTIONS.md("Typed indices") exists to prevent exactly this. - Unit-vector invariant.
Direction2D d = someUnnormalizedVector;compiles and produces aDirection2Dthat is not unit length. Every consumer that assumes normalization is now wrong.
The outbound direction (wrapper to payload) is defensible: the value really is that payload. The inbound direction is the unsound one — it asserts an invariant the source value has not been checked against.
The Direction2D case has direct evidence that the library considers the inbound direction a
real operation and not a coercion: numeric-structures.library.plato offers FromVector (which
normalizes) and FromVectorUnchecked (which asserts), deliberately named so a reader sees which
one they took. The emitted implicit operator Direction2D(Vector2D) is a third, nameless door
that does neither.
Relationship to the conversion convention
stdlib/CONVENTIONS.md section "Conversions" governs conversions written in Plato source, and
tests/PlatoTests/ImplicitCastInventoryTests.cs pins them. Neither covers these. The field
mirror is minted by the writer from the type's shape, so it appears in no Plato declaration and in
no cast-relation set (TypeRelations.ComputeCasts). The cast inventory golden is therefore a
partial view of what the shipped C# actually coerces.
Options
- Emit the outbound conversion implicitly and the inbound one as
explicit. Keeps the convenience of reading the payload out; makes constructing the wrapper a visible decision. Likely the smallest sound change; will require call-site fixes wherever generated or handwritten code relies on the inbound direction. - Keep both, but only for types that opt in (a marker on the Plato declaration). More control, more vocabulary surface.
- Extend the cast-inventory pin to also record writer-emitted operators, so at minimum the set is visible and cannot grow unnoticed. This does not fix the unsoundness but stops it spreading, and is independent of 1 and 2.
Resolution (2026-08-02, 1105851)
Option 1 was taken in its stronger form: the inbound direction is not emitted at all rather
than demoted to explicit. An explicit operator would still be a nameless door — for
Direction2D it reads as routine while asserting normalization — and it would be ambiguous
(CS0457) against a Plato-declared implicit conversion for the same pair.
The deciding fact is that the LANGUAGE never had the inbound conversion: SymbolFactory
synthesizes only the unwrap cast for a one-field type and a constructor for the wrap, so
ComputeCasts holds no wrap relation. Emitting one made the C# coerce where Plato does not.
Removing it also un-suppressed genuine declared casts: CSharpTypeWriter used to skip
Angle(x: Number): Angle because the shape mirror covered the pair, so the pinned
Number -> Angle relation had no C# counterpart of its own. It does now, in _Number.g.cs.
Fallout was nil — no stdlib body was leaning on the nameless wrap.
Done means
- A decision is recorded in
tracker/decisions/on which directions the field mirror may emit —2026-08-02-single-field-mirror-unwraps-only.md - Generated C# matches that decision — both shipping-tier regens carry unwrap only
- The pin (or a sibling pin) covers writer-emitted implicit operators, not only Plato-declared
casts —
tests/PlatoTests/EmittedConversionInventoryTests.csinventories the conversion operators the writer emits, read out of the emitted text rather than the cast relations