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:
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.
- It then calls
JsonConvert.SerializeObject(valueObj, _jsonSerializerSettings) to stamp out the JSON literal.
_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).
- 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.
- 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
Summary
When generating TypeScript for the C# "Enumeration" / smart-enum pattern (Jimmy Bogard, eShopOnContainers, Vladimir Khorikov, etc.), every
static readonlyfield of the class loses its default-value literal in the generated.tsoutput and is emitted as an uninitialized declaration. The runtime values becomeundefinedon 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:
Expected output (TypeGen 3.1.0 behavior)
Actual output (TypeGen 4.x – 7.0.0)
The fields are declared but never initialized, so
SystemRole.SuperAdmin.Namereturnsundefinedat runtime.Root cause
Running with
--verboseexposes the silenced exception (caught at thecatch (Exception)inTsContentGenerator.GetMemberValueText):The chain:
TsContentGenerator.GetMemberValueText(fieldInfo)reads the static field value viafieldInfo.GetValue(null)— this correctly triggers the static constructor and produces the right instance. So far so good.JsonConvert.SerializeObject(valueObj, _jsonSerializerSettings)to stamp out the JSON literal._jsonSerializerSettingsuses the customTsJsonContractResolver, whoseGetSerializableMembers(objectType)returnsobjectType.GetTsExportableMembers(...). This list includes the static fields ofSystemRole(because they are exportable members — we legitimately wantstatic readonly SuperAdmin: SystemRoledeclarations in the generated.ts).SystemRole.SuperAdmin, the resolver instructs Newtonsoft to walk into the static fieldSuperAdminof typeSystemRole— which evaluates back to the same instance being serialized. Infinite loop →ReferenceLoopException.Debuglevel and returnsnull, so the default-value literal is dropped.The custom contract resolver is overloading two distinct concerns:
DefaultContractResolverreturns.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. AJsonConvert.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. KeepTsJsonContractResolverfor 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.0warnings during generation but output is identical to prior runs).Environment
tgconfig.jsonflags exercising this path