Skip to content

Support Java 16 record classes (#65) - #163

Merged
paulirwin merged 2 commits into
masterfrom
issue/65-records
Aug 16, 2026
Merged

Support Java 16 record classes (#65)#163
paulirwin merged 2 commits into
masterfrom
issue/65-records

Conversation

@paulirwin

Copy link
Copy Markdown
Owner

Fixes #65.

Converts Java 16 record classes to C# records.

record Circle(int radius) implements Shape {
    public String describe() { return "Circle r=" + radius; }
}
public record Circle(int radius) : Shape
{
    public virtual string Describe() { return "Circle r=" + radius; }
}

Covers components, implements, generics, static members, nested records, secondary constructors (emitted as : this(v, v)), and value equality.

Also fixes a silent data-loss bug

The type dispatch in JavaToCSharpConverter had no else branch, so a top-level record was silently dropped — empty output, no warning, no error. Nested records instead threw from BodyDeclarationVisitor's exact-type lookup. Records already parsed, since the language level is JAVA_17; nothing consumed them.

The new else branch means any future unsupported type declaration warns rather than disappearing.

Compact constructors

A compact constructor's body runs against the constructor parameters and may reassign them, with the components assigned from those parameters afterwards. Verified against real Java:

public Point { y = y * 2; }   // new Point(1, 5)  ->  Point[x=1, y=10]

So the body cannot be lowered to property initializers — those would capture the original arguments and silently produce y=5. Such records are emitted in non-positional form instead:

public record Point
{
    public int x { get; init; }
    public int y { get; init; }

    public Point(int x, int y)
    {
        if (x < 0) throw new ArgumentException("neg");
        y = y * 2;
        this.x = x;   // assigned after the body, preserving the reassignment
        this.y = y;
    }
}

Value equality, ToString, and with still come from the record. Only the implicit Deconstruct is lost, and record deconstruction is not converted today.

Explicit canonical constructors need the same non-positional form to avoid colliding with the generated primary constructor (CS0111), and already assign the components themselves, so they are ported too. Java forbids declaring both a compact and a canonical constructor, so at most one is ever present.

Known limitation

Explicit accessor overrides (public int x()) are skipped with a warning. The component property exists in either form, so emitting the method would be a duplicate member (CS0102).

Separately, and not addressed here: accessor call sites convert incorrectly. MethodCallExpressionVisitor capitalizes every method call unconditionally, so Java p.x() becomes p.X(), matching neither the property's name nor its kind. Distinguishing an accessor call from an ordinary one needs type resolution. Worth a follow-up issue.

Component names are deliberately kept verbatim rather than capitalized to C# convention: bodies refer to components directly (x + y) and those references are not rewritten, so renaming them would emit code that does not compile.

Testing

Java16Records.java runs through the integration harness that compiles and executes the generated C#. Its output is byte-identical to that of the equivalent Java program run under java, including the reassigned component (range=1..50).

Plus 12 focused unit tests in ConvertRecordTests.cs. Full suite: 311 passing, clean build under TreatWarningsAsErrors.

Two fixture details work around pre-existing behaviors unrelated to records, rather than expanding this PR's scope: members are explicitly public (Java's package-private default maps to C# private, reproducible on a plain class), and e.getMessage() is avoided (it converts to .GetMessage() rather than .Message).

🤖 Generated with Claude Code

paulirwin and others added 2 commits August 16, 2026 07:32
Java records are converted to C# positional records: components become
positional parameters, `implements` becomes the base list, and members are
converted through the existing class member pipeline.

Previously a top-level record was silently dropped, because the type dispatch
in JavaToCSharpConverter had no fallback branch, and a nested record threw
from BodyDeclarationVisitor's exact-type lookup. Add an `else` branch so any
future unsupported type declaration warns instead of disappearing.

Record component names are kept verbatim rather than capitalized. Bodies
refer to components directly (`x + y`) and those references are not
rewritten, so renaming the components would emit code that does not compile.

Three Java constructs have no positional-record equivalent in C# and are
skipped with a warning rather than emitting code that fails to compile:

- Compact constructors, which have no C# counterpart.
- Explicit canonical constructors, which collide with the generated primary
  constructor (CS0111).
- Explicit component accessors, which collide with the generated property
  (CS0102).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A compact constructor's body runs against the constructor parameters and may
reassign them, with the component fields assigned from those parameters
afterwards. Verified against Java: given `y = y * 2` in the body, the stored
component holds the doubled value, so the body cannot be lowered to property
initializers, which would capture the original arguments.

Emit such records in non-positional form instead: explicit `{ get; init; }`
properties plus a constructor holding the converted compact body followed by
the component assignments. Value equality, `ToString`, and `with` still come
from the record itself; only the implicit `Deconstruct` is lost, and record
deconstruction is not converted today.

An explicit canonical constructor needs the same non-positional form to avoid
colliding with the generated primary constructor, and already assigns the
components itself, so it is now ported rather than dropped with a warning.
Java forbids declaring both a compact and a canonical constructor, so at most
one is ever present.

The integration fixture executes the converted code and its output matches
that of the equivalent Java program, including the reassigned component.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@paulirwin
paulirwin marked this pull request as ready for review August 16, 2026 19:30
@paulirwin
paulirwin merged commit 09cc847 into master Aug 16, 2026
5 checks passed
@paulirwin
paulirwin deleted the issue/65-records branch August 16, 2026 19:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Java 16 Record Classes

1 participant