Skip to content

TypeGen 7.0.0 silently drops default values for static-readonly self-typed fields (Enumeration / smart-enum pattern) due to ReferenceLoopHandling not set #236

@mmarcucciofw

Description

@mmarcucciofw

Summary

When generating TypeScript for the C# "Enumeration" / smart-enum pattern (Jimmy Bogard, eShopOnContainers, Vladimir Khorikov, etc.), every static readonly field of the class loses its default-value literal in the generated .ts output and is emitted as an uninitialized declaration. The runtime values become undefined on the TS side.

I confirmed this on TypeGen 7.0.0 against a project containing 104 such enumerations and all 104 fail with the same root cause. The behavior worked correctly in TypeGen 3.1.0.

Repro

Minimal C# class using the standard smart-enum pattern:

[ExportTsClass]
public abstract record Enumeration<TEnumType, TIdType> : IEnumeration<TIdType>
    where TEnumType : Enumeration<TEnumType, TIdType>
    where TIdType : IComparable<TIdType>
{
    public TIdType Value { get; init; }
    public string Name { get; init; }
    protected Enumeration(TIdType value, string name = null) { Value = value; Name = name; }
}

[ExportTsClass]
public record SystemRole : Enumeration<SystemRole, string>
{
    public static readonly SystemRole SuperAdmin = new("Super Admin", "Super Admin");
    public static readonly SystemRole Admin     = new("Admin", "Admin");
    private SystemRole(string value, string name) : base(value, name) {}
}

Expected output (TypeGen 3.1.0 behavior)

export class SystemRole extends Enumeration<SystemRole, string> implements IEnumeration<string> {
  static readonly SuperAdmin: SystemRole = {"Value":"Super Admin","Name":"Super Admin"};
  static readonly Admin: SystemRole = {"Value":"Admin","Name":"Admin"};
}

Actual output (TypeGen 4.x – 7.0.0)

export class SystemRole extends Enumeration<SystemRole, string> implements IEnumeration<string> {
  static readonly SuperAdmin: SystemRole;
  static readonly Admin: SystemRole;
}

The fields are declared but never initialized, so SystemRole.SuperAdmin.Name returns undefined at runtime.

Root cause

Running with --verbose exposes the silenced exception (caught at the catch (Exception) in TsContentGenerator.GetMemberValueText):

Cannot determine the default value for member 'SystemRole.SuperAdmin',
because an unknown exception occurred:
'Self referencing loop detected for property 'SuperAdmin' with type 'SystemRole'. Path ''.'

The chain:

  1. TsContentGenerator.GetMemberValueText(fieldInfo) reads the static field value via fieldInfo.GetValue(null) — this correctly triggers the static constructor and produces the right instance. So far so good.
  2. It then calls JsonConvert.SerializeObject(valueObj, _jsonSerializerSettings) to stamp out the JSON literal.
  3. _jsonSerializerSettings uses the custom TsJsonContractResolver, whose GetSerializableMembers(objectType) returns objectType.GetTsExportableMembers(...). This list includes the static fields of SystemRole (because they are exportable members — we legitimately want static readonly SuperAdmin: SystemRole declarations in the generated .ts).
  4. While serializing the root instance SystemRole.SuperAdmin, the resolver instructs Newtonsoft to walk into the static field SuperAdmin of type SystemRole — which evaluates back to the same instance being serialized. Infinite loop → ReferenceLoopException.
  5. The catch block silently logs at Debug level and returns null, so the default-value literal is dropped.

The custom contract resolver is overloading two distinct concerns:

  • "Which members should I generate TS declarations for?" — legitimately includes static fields.
  • "Which members should I JSON-serialize when stamping a single field's default-value literal?" — should be instance-only, the same set Newtonsoft's DefaultContractResolver returns.

By using the same enumeration for both, the JSON serializer is told to recurse through the type's static graph, which loops on any class where a static field has the type of its containing class — i.e. every smart-enum.

Why this matters

The static-readonly / smart-enum pattern is a mainstream C# idiom — see Microsoft's Enumeration class, eShopOnContainers, and Ardalis.SmartEnum. It's also conceptually identical to BCL members like String.Empty, Guid.Empty, Color.Red. A JsonConvert.SerializeObject(SystemRole.SuperAdmin) with the default contract resolver handles it without trouble; the custom resolver is what opts into the loop.

In our codebase, this affects 104 enumeration types across every domain aggregate. The output of 3.1.0 was correct and is what the consuming Angular code depends on, so we cannot upgrade past 3.1.0 today.

Proposed fix

Either of these would resolve it; (a) is the cleaner separation of concerns.

(a) Use the default Newtonsoft contract resolver (or an instance-only variant) for the default-value JSON serialization in TsContentGenerator. Keep TsJsonContractResolver for what it's actually about — emitting member declarations, not recursively serializing them.

Happy to send a PR if you'd like.

Workaround

Pin to TypeGen 3.1.0 — still functions against .NET 10 (emits a few harmless Could not resolve assembly: System.Runtime, Version=10.0.0.0 warnings during generation but output is identical to prior runs).

Environment

  • TypeGen 7.0.0 (CLI + Core)
  • .NET 10.0.107
  • Target project: .NET 10
  • OS: Linux (WSL2)
  • Newtonsoft.Json default settings; no custom tgconfig.json flags exercising this path

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions