Skip to content

feat(csharp): rework enum serialization to eliminate reflection#13519

Merged
fern-support merged 10 commits intomainfrom
devin/FER-4622-1773421060
Mar 13, 2026
Merged

feat(csharp): rework enum serialization to eliminate reflection#13519
fern-support merged 10 commits intomainfrom
devin/FER-4622-1773421060

Conversation

@Swimburger
Copy link
Member

@Swimburger Swimburger commented Mar 13, 2026

Description

Linear ticket: Closes FER-4622

Eliminates all reflection usage from C# enum JSON serialization by generating per-enum serializer classes with compile-time dictionary lookups.

Link to Devin Session
Requested by: @Swimburger

Changes Made

Regular enums (EnumGenerator.ts / Enum.ts):

  • Each enum now generates a companion {EnumName}Serializer class in the same file
  • Two static readonly dictionaries (_stringToEnum, _enumToString) provide O(1) bidirectional mapping
  • Read uses _stringToEnum.TryGetValue() — returns default for unknown values
  • Write uses _enumToString.TryGetValue() — writes null for unknown values
  • Replaces [JsonConverter(typeof(EnumSerializer<T>))][JsonConverter(typeof(TSerializer))]

String enums (StringEnumGenerator.ts):

  • Each string enum now generates a nested {EnumName}Serializer class inside the record struct
  • Read: new MyEnum(stringValue) — no reflection needed (previously used Activator.CreateInstance)
  • Write: writer.WriteStringValue(value.{propertyName}) — uses the actual property name (may be Value_ if a member is named "Value")
  • Replaces [JsonConverter(typeof(StringEnumSerializer<T>))][JsonConverter(typeof(T.TSerializer))]

Removed generic serializer files from as-is includes:

  • EnumSerializer.Template.cs and StringEnumSerializer.Template.cs are no longer included in generated output
  • Corresponding test template files (EnumSerializerTests, StringEnumSerializerTests) no longer included either

Updated template test files to use inline serializers instead of generic ones.

  • Updated README.md generator (not applicable)

Updates Since Last Revision

  • Refactored regular enum serializers to use dictionary lookups instead of switch statements, per review feedback. Two static readonly Dictionary fields provide bidirectional mapping; Read/Write use TryGetValue().
  • Resolved second merge conflict in generators/csharp/sdk/versions.yml — bumped SDK version to 2.28.0 (main claimed 2.27.0 for gRPC changes).
  • Previous fixes retained: undiscriminated-unions Value_ property fix, biome formatting fixes, changelog validation fixes.

Testing

  • Seed tests: all 6 exhaustive fixture variants pass (build + tests)
  • Seed tests: undiscriminated-unions fixture passes (build + tests) — validates the Value property rename edge case
  • Seed tests: enum fixture (both plain-enums and forward-compatible-enums) passes
  • Full CI: all 61 checks pass

Human Review Checklist

  • Dictionary Write behavior for unknown values: The dictionary-based Write method writes null for unmapped enum values (via TryGetValue returning false). The previous switch-based approach threw ArgumentOutOfRangeException. Verify this is the desired behavior for forward compatibility.
  • QueryStringConverterTest.Template.cs still uses switch-based serializer: The test helper TestEnumSerializer in this file uses switch statements rather than dictionaries. This is a test-only helper (not generated code), but is inconsistent with the generated pattern. May want to align for clarity.
  • StringEnumGenerator.ts — serializer created after valueProperty: The nested serializer class is intentionally defined after valueProperty (line ~227) so that the Write method can reference valueProperty.name. If anyone reorders this code and moves the serializer back before valueProperty, the hardcoded value.Value bug will return for enums with a "Value" member. Worth verifying in the undiscriminated-unions/KeyType.cs seed output that value.Value_ is generated correctly.
  • Enum.ts:writeSerializerClass() — The companion serializer is written via raw string generation rather than the AST builder. Verify the generated dictionary initializers handle edge cases (special chars in wire values, default fallback for unknown values on deserialization).
  • Extensions.cs Stringify still uses reflection — Determined to be unused in generated code, so left as-is. Confirm this is acceptable.
  • popScope(false) calls in Enum.ts — Used to close dictionary initializer blocks without trailing newline. Verify the generated C# formatting is correct (semicolons placed correctly after closing braces).

Open with Devin

- Regular enums now generate a companion serializer class with compile-time switch statements instead of using generic EnumSerializer<T>
- String enums now generate a nested serializer class instead of using StringEnumSerializer<T> which relied on Activator.CreateInstance
- Remove generic EnumSerializer.Template.cs and StringEnumSerializer.Template.cs from as-is includes
- Update template test files to use inline serializers

Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
@devin-ai-integration
Copy link
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 4 additional findings.

Open in Devin Review

devin-ai-integration bot and others added 2 commits March 13, 2026 17:45
… handle renamed Value property

Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
} No newline at end of file
}

internal class DummyEnumSerializer : JsonConverter<DummyEnum>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can do better. Let's create two maps enum -> string and string -> enum and just do lookups instead of switch statements.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea — I'll rework regular enum serializers to use Dictionary<string, TEnum> and Dictionary<TEnum, string> lookups instead of switch statements.


public static bool operator !=(DummyEnum value1, string value2) => !value1.Value.Equals(value2);

internal class DummyEnumSerializer : JsonConverter<DummyEnum>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need an inline serializer, what was the previous StringEnumSerializer coming short for?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous StringEnumSerializer<T> used Activator.CreateInstance (reflection) to construct the string enum instance from the deserialized string value — that's the core thing FER-4622 asked us to eliminate. The inline serializer replaces that with a direct new MyEnum(stringValue) call, no reflection needed.

For string enums specifically, the serializer is simple (just construct from string / read the Value property), so the inline version is lightweight.

devin-ai-integration bot and others added 3 commits March 13, 2026 18:20
…m serializers

Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>

public override void Write(System.Text.Json.Utf8JsonWriter writer, DummyEnum value, JsonSerializerOptions options)
{
writer.WriteStringValue(_enumToString.TryGetValue(value, out var stringValue) ? stringValue : null);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Write method writes null for unknown enum values instead of throwing an exception. This is inconsistent with the test template in QueryStringConverterTest.Template.cs:170 which throws an ArgumentOutOfRangeException. Writing null can cause silent data corruption when an invalid enum value is serialized.

// Current (problematic):
writer.WriteStringValue(_enumToString.TryGetValue(value, out var stringValue) ? stringValue : null);

// Should be:
writer.WriteStringValue(_enumToString[value]); // Throws if key not found
Suggested change
writer.WriteStringValue(_enumToString.TryGetValue(value, out var stringValue) ? stringValue : null);
writer.WriteStringValue(_enumToString[value]);

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

devin-ai-integration bot and others added 3 commits March 13, 2026 18:59
Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
…8.0 from main)

Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
@fern-support fern-support enabled auto-merge (squash) March 13, 2026 19:12
@fern-support fern-support merged commit 85557d6 into main Mar 13, 2026
87 checks passed
@fern-support fern-support deleted the devin/FER-4622-1773421060 branch March 13, 2026 19:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants