Skip to content

Commit 8826560

Browse files
Add prototype support for C# union types, closed enums, and closed hierarchies
This commit introduces experimental support for three upcoming C# language features: 1. **C# Union Types** - Maps to existing IUnionTypeShape abstraction via new UnionKind enum (ClassHierarchy, FSharpUnion, CSharpUnion). Uses DelegateMarshaler<TSource, TTarget> for marshal/unmarshal between unrelated case types and union types. Detection via [Union] attribute + IUnion interface by metadata name. 2. **Closed Enums** - Adds IsClosed property to IEnumTypeShape, detected via [Closed] attribute by metadata name. Supported in both reflection and source generator providers. 3. **Closed Hierarchies** - [ClosedSubtype] attributes are always honored for union detection. Assembly scanning fallback requires InferDerivedTypes opt-in via TypeShapeAttribute property. Key design decisions: - No breaking changes to public interfaces (InternalImplementationsOnly) - Detection by metadata name avoids shipping BCL-conflicting types - IUnionCaseShape has no TUnionCase:TUnion constraint at interface level, enabling C# union support without new abstractions - Source gen uses string UnionKindName to avoid accessibility issues - Example JSON serializer extended with structural matching for C# unions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent da5ac94 commit 8826560

39 files changed

Lines changed: 1163 additions & 5 deletions
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using PolyType.Abstractions;
2+
using System.Text.Json;
3+
using System.Text.Json.Serialization;
4+
5+
namespace PolyType.Examples.JsonSerializer.Converters;
6+
7+
internal sealed class JsonCSharpUnionConverter<TUnion>(
8+
Getter<TUnion, int> getUnionCaseIndex,
9+
JsonUnionCaseConverter<TUnion>[] unionCaseConverters) : JsonConverter<TUnion>
10+
{
11+
public override TUnion? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
12+
{
13+
if (default(TUnion) is null && reader.TokenType is JsonTokenType.Null)
14+
{
15+
return default;
16+
}
17+
18+
int bestIndex = FindBestMatchingCase(ref reader);
19+
if (bestIndex < 0)
20+
{
21+
throw new JsonException($"Unable to match JSON token to any case type of union '{typeof(TUnion)}'.");
22+
}
23+
24+
return unionCaseConverters[bestIndex].Read(ref reader, typeToConvert, options);
25+
}
26+
27+
public override void Write(Utf8JsonWriter writer, TUnion value, JsonSerializerOptions options)
28+
{
29+
if (value is null)
30+
{
31+
writer.WriteNullValue();
32+
return;
33+
}
34+
35+
int index = getUnionCaseIndex(ref value);
36+
if (index < 0)
37+
{
38+
throw new JsonException($"Unable to determine union case for value of type '{value?.GetType()}'.");
39+
}
40+
41+
unionCaseConverters[index].WriteDirect(writer, value, options);
42+
}
43+
44+
private int FindBestMatchingCase(ref Utf8JsonReader reader)
45+
{
46+
// Simple structural matching: score each case type against the JSON token.
47+
// For objects, count how many JSON property names match known properties on each candidate.
48+
// For primitives, check binary token type compatibility.
49+
50+
JsonTokenType token = reader.TokenType;
51+
int bestIndex = -1;
52+
int bestScore = -1;
53+
54+
for (int i = 0; i < unionCaseConverters.Length; i++)
55+
{
56+
int score = unionCaseConverters[i].ScoreAgainstToken(token, ref reader);
57+
if (score > bestScore)
58+
{
59+
bestScore = score;
60+
bestIndex = i;
61+
}
62+
}
63+
64+
return bestIndex;
65+
}
66+
}

src/PolyType.Examples/JsonSerializer/Converters/JsonObjectConverter.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ namespace PolyType.Examples.JsonSerializer.Converters;
1010
internal class JsonObjectConverter<T>(JsonPropertyConverter<T>[] properties) : JsonConverter<T>, IJsonObjectConverter<T>
1111
{
1212
private readonly JsonPropertyConverter<T>[] _propertiesToWrite = properties.Where(prop => prop.HasGetter).ToArray();
13+
private readonly JsonPropertyDictionary<JsonPropertyConverter<T>>? _propertyLookup = properties.Length > 0
14+
? properties.ToJsonPropertyDictionary(p => p.Name)
15+
: null;
16+
17+
public bool HasProperty(ref Utf8JsonReader reader) => _propertyLookup?.LookupProperty(ref reader) is not null;
1318

1419
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
1520
{

src/PolyType.Examples/JsonSerializer/Converters/JsonUnionCaseConverter.cs

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Diagnostics;
1+
using System.Collections;
2+
using System.Diagnostics;
23
using System.Text.Json;
34
using System.Text.Json.Serialization;
45

@@ -7,6 +8,18 @@ namespace PolyType.Examples.JsonSerializer.Converters;
78
internal abstract class JsonUnionCaseConverter<TUnion> : JsonConverter<TUnion>
89
{
910
public abstract string Name { get; }
11+
12+
/// <summary>
13+
/// Scores this case type against a JSON token for structural matching.
14+
/// Higher scores indicate better matches. Returns -1 for incompatible tokens.
15+
/// </summary>
16+
public virtual int ScoreAgainstToken(JsonTokenType token, ref Utf8JsonReader reader) => 0;
17+
18+
/// <summary>
19+
/// Writes the union value directly (without discriminator wrapping) for C# union serialization.
20+
/// </summary>
21+
public virtual void WriteDirect(Utf8JsonWriter writer, TUnion value, JsonSerializerOptions options) =>
22+
Write(writer, value, options);
1023
}
1124

1225
internal sealed class JsonUnionCaseConverter<TUnionCase, TUnion>(string name, IMarshaler<TUnionCase, TUnion> marshaler, JsonConverter<TUnionCase> underlying) : JsonUnionCaseConverter<TUnion>
@@ -54,4 +67,74 @@ public override void Write(Utf8JsonWriter writer, TUnion value, JsonSerializerOp
5467
underlying.Write(writer, marshaler.Unmarshal(value)!, options);
5568
}
5669
}
70+
71+
public override void WriteDirect(Utf8JsonWriter writer, TUnion value, JsonSerializerOptions options)
72+
{
73+
TUnionCase? caseValue = marshaler.Unmarshal(value);
74+
underlying.Write(writer, caseValue!, options);
75+
}
76+
77+
public override int ScoreAgainstToken(JsonTokenType token, ref Utf8JsonReader reader)
78+
{
79+
// Binary token type compatibility check
80+
Type caseType = typeof(TUnionCase);
81+
82+
return token switch
83+
{
84+
JsonTokenType.Number => IsNumericType(caseType) ? 1 : -1,
85+
JsonTokenType.String => IsStringType(caseType) ? 1 : -1,
86+
JsonTokenType.True or JsonTokenType.False => caseType == typeof(bool) ? 1 : -1,
87+
JsonTokenType.StartArray => IsArrayType(caseType) ? 1 : -1,
88+
JsonTokenType.StartObject when _objectConverter is not null => ScoreObjectMatch(ref reader),
89+
JsonTokenType.StartObject => 0,
90+
_ => 0,
91+
};
92+
}
93+
94+
private int ScoreObjectMatch(ref Utf8JsonReader reader)
95+
{
96+
// Count how many JSON property names match known properties of this case type
97+
if (underlying is not JsonObjectConverter<TUnionCase> objectConverter)
98+
{
99+
return 0;
100+
}
101+
102+
Utf8JsonReader checkpoint = reader;
103+
int matchCount = 0;
104+
105+
try
106+
{
107+
checkpoint.EnsureRead(); // past StartObject
108+
while (checkpoint.TokenType == JsonTokenType.PropertyName)
109+
{
110+
if (objectConverter.HasProperty(ref checkpoint))
111+
{
112+
matchCount++;
113+
}
114+
115+
checkpoint.EnsureRead(); // past property name
116+
checkpoint.Skip(); // skip value
117+
checkpoint.EnsureRead(); // to next property or EndObject
118+
}
119+
}
120+
catch (JsonException)
121+
{
122+
return 0;
123+
}
124+
125+
return matchCount;
126+
}
127+
128+
private static bool IsNumericType(Type type) =>
129+
type == typeof(int) || type == typeof(long) || type == typeof(double) ||
130+
type == typeof(float) || type == typeof(decimal) || type == typeof(short) ||
131+
type == typeof(byte) || type == typeof(uint) || type == typeof(ulong);
132+
133+
private static bool IsStringType(Type type) =>
134+
type == typeof(string) || type == typeof(DateTime) || type == typeof(DateTimeOffset) ||
135+
type == typeof(Guid) || type == typeof(Uri) || type.IsEnum;
136+
137+
private static bool IsArrayType(Type type) =>
138+
type != typeof(string) &&
139+
(type.IsArray || typeof(System.Collections.IEnumerable).IsAssignableFrom(type));
57140
}

src/PolyType.Examples/JsonSerializer/JsonSerializer.Builder.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,11 @@ public JsonConverter<T> GetOrAddConverter<T>(ITypeShape<T> shape) =>
224224
.Select(unionCase => (JsonUnionCaseConverter<TUnion>)unionCase.Accept(this, null)!)
225225
.ToArray();
226226

227-
return new JsonUnionConverter<TUnion>(getUnionCaseIndex, baseTypeConverter, unionCases);
227+
return unionShape.UnionKind switch
228+
{
229+
UnionKind.CSharpUnion => new JsonCSharpUnionConverter<TUnion>(getUnionCaseIndex, unionCases),
230+
_ => new JsonUnionConverter<TUnion>(getUnionCaseIndex, baseTypeConverter, unionCases),
231+
};
228232
}
229233

230234
public override object? VisitUnionCase<TUnionCase, TUnion>(IUnionCaseShape<TUnionCase, TUnion> unionCaseShape, object? state)

src/PolyType.Roslyn/Model/EnumDataModel.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,9 @@ public sealed class EnumDataModel : TypeDataModel
2424
/// Gets a value indicating whether the enum is annotated with the <see cref="FlagsAttribute"/>.
2525
/// </summary>
2626
public required bool IsFlags { get; init; }
27+
28+
/// <summary>
29+
/// Gets a value indicating whether the enum is a closed enum.
30+
/// </summary>
31+
public required bool IsClosed { get; init; }
2732
}

src/PolyType.Roslyn/ModelGenerator/TypeDataModelGenerator.Enum.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,16 @@ private bool TryMapEnum(ITypeSymbol type, ref TypeDataModelGenerationContext ctx
3232
bool isFlags = KnownSymbols.FlagsAttribute is { } flagsAttr &&
3333
enumType.GetAttributes().Any(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, flagsAttr));
3434

35+
bool isClosed = enumType.GetAttributes().Any(attr => attr.AttributeClass?.ToDisplayString() == "System.Runtime.CompilerServices.ClosedAttribute");
36+
3537
model = new EnumDataModel
3638
{
3739
Type = type,
3840
Requirements = TypeShapeRequirements.Full,
3941
UnderlyingType = underlyingType,
4042
Members = members,
4143
IsFlags = isFlags,
44+
IsClosed = isClosed,
4245
};
4346

4447
return true;

src/PolyType.SourceGenerator/Model/EnumShapeModel.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ public sealed record EnumShapeModel : TypeShapeModel
99
public required ImmutableEquatableDictionary<string, string> Members { get; init; }
1010

1111
public required bool IsFlags { get; init; }
12+
13+
public required bool IsClosed { get; init; }
1214
}

src/PolyType.SourceGenerator/Model/UnionShapeModel.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ public sealed record UnionShapeModel : TypeShapeModel
66
{
77
public required TypeShapeModel UnderlyingModel { get; init; }
88

9+
/// <summary>
10+
/// Gets the name of the <c>UnionKind</c> enum member for this shape (e.g., "ClassHierarchy", "FSharpUnion", "CSharpUnion").
11+
/// </summary>
12+
public required string UnionKindName { get; init; }
13+
914
/// <summary>
1015
/// The list of known derived types for the given type in topological order from most to least derived.
1116
/// </summary>

src/PolyType.SourceGenerator/Parser/Parser.ModelMapper.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ private TypeShapeModel MapModelCore(TypeDataModel model, TypeId typeId, string s
3636
Members = enumModel.Members.ToImmutableEquatableDictionary(m => m.Key, m => EnumValueToString(m.Value)),
3737
Attributes = CollectAttributes(model.Type),
3838
IsFlags = enumModel.IsFlags,
39+
IsClosed = enumModel.IsClosed,
3940
},
4041

4142
OptionalDataModel optionalModel => new OptionalShapeModel
@@ -397,6 +398,7 @@ private UnionShapeModel MapUnionModel(TypeDataModel model, TypeShapeModel underl
397398
Attributes = CollectAttributes(model.Type),
398399
Methods = MapMethods(model, underlyingIncrementalModel.Type),
399400
Events = MapEvents(model, underlyingIncrementalModel.Type),
401+
UnionKindName = nameof(UnionKind.ClassHierarchy),
400402
UnionCases = model.DerivedTypes
401403
.Select(derived => new UnionCaseModel
402404
{
@@ -923,12 +925,14 @@ private void ParseTypeShapeAttribute(
923925
out TypeShapeKind? kind,
924926
out ITypeSymbol? marshaler,
925927
out MethodShapeFlags? includeMethodFlags,
926-
out Location? location)
928+
out Location? location,
929+
out bool inferDerivedTypes)
927930
{
928931
kind = null;
929932
marshaler = null;
930933
location = null;
931934
includeMethodFlags = null;
935+
inferDerivedTypes = false;
932936

933937
if (typeSymbol.GetAttribute(_knownSymbols.TypeShapeAttribute) is AttributeData propertyAttr)
934938
{
@@ -946,6 +950,9 @@ private void ParseTypeShapeAttribute(
946950
case "IncludeMethods":
947951
includeMethodFlags = (MethodShapeFlags)namedArgument.Value.Value!;
948952
break;
953+
case "InferDerivedTypes":
954+
inferDerivedTypes = namedArgument.Value.Value is true;
955+
break;
949956
}
950957
}
951958
}

src/PolyType.SourceGenerator/Parser/Parser.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,16 @@ protected override IEnumerable<DerivedTypeModel> ResolveDerivedTypes(ITypeSymbol
613613

614614
derivedType = dt;
615615
}
616+
else if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, _knownSymbols.ClosedSubtypeAttribute))
617+
{
618+
// [ClosedSubtype(typeof(T))] — emitted by the compiler for closed hierarchies
619+
if (attribute.ConstructorArguments is not [{ Value: ITypeSymbol closedDt }])
620+
{
621+
continue;
622+
}
623+
624+
derivedType = closedDt;
625+
}
616626
else
617627
{
618628
continue;
@@ -680,7 +690,8 @@ protected override TypeDataModelGenerationStatus MapType(ITypeSymbol type, TypeD
680690
out TypeShapeKind? attrDeclaredKind,
681691
out ITypeSymbol? marshaler,
682692
out MethodShapeFlags? attrMethodBindingFlags,
683-
out Location? typeShapeLocation);
693+
out Location? typeShapeLocation,
694+
out bool inferDerivedTypes);
684695

685696
TypeExtensionModel? typeExtensionModel = GetExtensionModel(type);
686697
if (typeExtensionModel is not null)

0 commit comments

Comments
 (0)