Skip to content

Commit 0f22d42

Browse files
feat(lib): Improve handling of types with generic arguments #123
1 parent 36202a1 commit 0f22d42

9 files changed

Lines changed: 220 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [unreleased]
99

10+
### Added
11+
12+
- Better support for generic types (#123)
13+
1014
## [0.16.0] - 2024-12-17
1115

1216
### Added

TypeContractor.Tests/TypeScript/TypeScriptConverterTests.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,26 @@ public void Handles_Nullable_Records_Inside_Other_Records()
343343
second.IsNullable.Should().BeTrue();
344344
}
345345

346+
[Fact]
347+
public void Handles_Generics()
348+
{
349+
var result = Sut.Convert(typeof(ResponseWithOverrides));
350+
351+
result.Should().NotBeNull();
352+
result.Properties.Should().NotBeNull();
353+
result.Properties!.Should().HaveCount(2);
354+
result.Properties!.First().DestinationType.Should().Be("Overridable<string>");
355+
result.Properties!.Last().DestinationType.Should().Be("Overridable<boolean>");
356+
357+
Sut.CustomMappedTypes.Should().ContainSingle();
358+
var overridableType = Sut.CustomMappedTypes.First().Value;
359+
overridableType.Properties.Should().HaveCount(2);
360+
overridableType.Properties!.First().DestinationName.Should().Be("value");
361+
overridableType.Properties!.First().DestinationType.Should().Be("T");
362+
overridableType.Properties!.Last().DestinationName.Should().Be("isOverridden");
363+
overridableType.Properties!.Last().DestinationType.Should().Be("boolean");
364+
}
365+
346366
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
347367
private record TopLevelRecord(string Name, SecondStoryRecord? SecondStoryRecord);
348368
private record SecondStoryRecord(string Description, SomeOtherDeeplyNestedRecord? SomeOtherDeeplyNestedRecord);
@@ -466,6 +486,18 @@ private class TimeOnlyResponse
466486
public TimeOnly MeetingTime { get; set; }
467487
}
468488

489+
private class Overridable<T>
490+
{
491+
public T? Value { get; set; }
492+
public bool IsOverridden { get; set; }
493+
}
494+
495+
private class ResponseWithOverrides
496+
{
497+
public Overridable<string> Name { get; set; }
498+
public Overridable<bool?> SomeBool { get; set; }
499+
}
500+
469501
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
470502

471503
private MetadataLoadContext BuildMetadataLoadContext()

TypeContractor.Tests/TypeScript/TypeScriptWriterTests.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,42 @@ public void Can_Write_Simple_Types()
4848
.And.Contain("someObject: any;");
4949
}
5050

51+
[Fact]
52+
public void Can_Write_Generic_Types()
53+
{
54+
// Arrange
55+
var outputTypes = BuildOutputTypes(typeof(ResponseWithOverrides));
56+
57+
// Act
58+
var responseResult = Sut.Write(outputTypes.First(x => x.Name == "ResponseWithOverrides"), outputTypes, true);
59+
var overrideResult = Sut.Write(outputTypes.First(x => x.Name == "Overridable"), outputTypes, true);
60+
61+
// Assert
62+
var responseFile = File.ReadAllLines(responseResult).Select(x => x.TrimStart());
63+
responseFile.Should()
64+
.NotBeEmpty()
65+
.And.Contain("import { Overridable, OverridableSchema } from './Overridable';")
66+
67+
.And.Contain("export interface ResponseWithOverrides {")
68+
.And.Contain("name: Overridable<string>;")
69+
.And.Contain("someBool: Overridable<boolean>;")
70+
71+
.And.Contain("export const ResponseWithOverridesSchema = z.object({")
72+
.And.Contain("name: OverridableSchema,")
73+
.And.Contain("someBool: OverridableSchema,");
74+
75+
var overrideFile = File.ReadAllLines(overrideResult).Select(x => x.TrimStart());
76+
overrideFile.Should()
77+
.NotBeEmpty()
78+
.And.Contain("export interface Overridable<T> {")
79+
.And.Contain("value?: T;")
80+
.And.Contain("isOverridden: boolean;")
81+
82+
.And.Contain("export const OverridableSchema = z.object({")
83+
.And.Contain("value: z.any().nullable(),")
84+
.And.Contain("isOverridden: z.boolean(),");
85+
}
86+
5187
[Fact]
5288
public void Handles_Dictionary_With_Complex_Values()
5389
{
@@ -483,3 +519,17 @@ public void Dispose()
483519
_outputDirectory.Delete(true);
484520
}
485521
}
522+
523+
#region Test input
524+
public class Overridable<T>
525+
{
526+
public T? Value { get; set; }
527+
public bool IsOverridden { get; set; }
528+
}
529+
530+
public class ResponseWithOverrides
531+
{
532+
public Overridable<string> Name { get; set; }
533+
public Overridable<bool?> SomeBool { get; set; }
534+
}
535+
#endregion

TypeContractor/Output/DestinationType.cs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,29 @@
11
namespace TypeContractor.Output;
22

3-
public record DestinationType(string TypeName, string? FullName, string ImportType, bool IsBuiltin, bool IsArray, bool IsReadonly, bool IsNullable, Type? InnerType)
3+
public record DestinationType(
4+
string TypeName,
5+
string? FullName,
6+
string ImportType,
7+
bool IsBuiltin,
8+
bool IsArray,
9+
bool IsReadonly,
10+
bool IsNullable,
11+
bool IsGeneric,
12+
ICollection<DestinationType> GenericTypeArguments,
13+
Type? SourceType,
14+
Type? InnerType)
415
{
5-
public DestinationType(string typeName, string? fullName, bool isBuiltin, bool isArray, bool isReadonly, bool isNullable, Type? innerType, string? importType = null) : this(typeName, fullName, importType ?? typeName, isBuiltin, isArray, isReadonly, isNullable, innerType)
16+
public DestinationType(string typeName,
17+
string? fullName,
18+
bool isBuiltin,
19+
bool isArray,
20+
bool isReadonly,
21+
bool isNullable,
22+
bool isGeneric,
23+
ICollection<DestinationType> genericTypeArguments,
24+
Type? innerType,
25+
Type? sourceType,
26+
string? importType = null) : this(typeName, fullName, importType ?? typeName, isBuiltin, isArray, isReadonly, isNullable, isGeneric, genericTypeArguments, sourceType, innerType)
627
{
728
}
829

TypeContractor/Output/OutputProperty.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
namespace TypeContractor.Output;
22

3-
public class OutputProperty(string sourceName, Type sourceType, Type? innerSourceType, string destinationName, string destinationType, string importType, bool isBuiltin, bool isArray, bool isNullable, bool isReadonly)
3+
public class OutputProperty(
4+
string sourceName,
5+
Type sourceType,
6+
Type? innerSourceType,
7+
string destinationName,
8+
string destinationType,
9+
string importType,
10+
bool isBuiltin,
11+
bool isArray,
12+
bool isNullable,
13+
bool isReadonly,
14+
bool isGeneric,
15+
ICollection<DestinationType> genericTypeArguments)
416
{
517
public string SourceName { get; set; } = sourceName;
618
public Type SourceType { get; set; } = sourceType;
@@ -12,6 +24,8 @@ public class OutputProperty(string sourceName, Type sourceType, Type? innerSourc
1224
public bool IsArray { get; set; } = isArray;
1325
public bool IsNullable { get; set; } = isNullable;
1426
public bool IsReadonly { get; set; } = isReadonly;
27+
public bool IsGeneric { get; set; } = isGeneric;
28+
public ICollection<DestinationType> GenericTypeArguments { get; } = genericTypeArguments;
1529
public ObsoleteInfo? Obsolete { get; set; }
1630

1731
/// <summary>
@@ -37,6 +51,8 @@ public override bool Equals(object? obj)
3751
IsArray == property.IsArray &&
3852
IsNullable == property.IsNullable &&
3953
IsReadonly == property.IsReadonly &&
54+
IsGeneric == property.IsGeneric &&
55+
GenericTypeArguments.SequenceEqual(property.GenericTypeArguments) &&
4056
EqualityComparer<ObsoleteInfo?>.Default.Equals(Obsolete, property.Obsolete);
4157
}
4258

@@ -53,6 +69,7 @@ public override int GetHashCode()
5369
hash.Add(IsArray);
5470
hash.Add(IsNullable);
5571
hash.Add(IsReadonly);
72+
hash.Add(IsGeneric);
5673
hash.Add(Obsolete);
5774
return hash.ToHashCode();
5875
}

TypeContractor/Output/OutputType.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
1-
using System.Globalization;
1+
using System.Globalization;
22
using System.Text;
33

44
namespace TypeContractor.Output;
55

6-
public record OutputType(string Name, string FullName, string FileName, ContractedType ContractedType, bool IsEnum, ICollection<OutputProperty>? Properties, ICollection<OutputEnumMember>? EnumMembers)
6+
public record OutputType(
7+
string Name,
8+
string FullName,
9+
string FileName,
10+
ContractedType ContractedType,
11+
bool IsEnum,
12+
bool IsGeneric,
13+
ICollection<DestinationType> GenericTypeArguments,
14+
ICollection<OutputProperty>? Properties,
15+
ICollection<OutputEnumMember>? EnumMembers)
716
{
817
public override string ToString()
918
{

TypeContractor/TypeScript/TypeScriptConverter.cs

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,16 @@ public OutputType Convert(Type type, ContractedType? contractedType = null)
1818
{
1919
ArgumentNullException.ThrowIfNull(type);
2020

21+
var typeName = type.Name.Split('`').First();
22+
2123
return new(
22-
type.Name,
24+
typeName,
2325
type.FullName!,
24-
CasingHelpers.ToCasing(type.Name.Replace("_", ""), configuration.Casing),
25-
contractedType ?? ContractedType.FromName(type.FullName!, type, configuration),
26+
CasingHelpers.ToCasing(typeName.Replace("_", ""), configuration.Casing),
27+
contractedType ?? ContractedType.FromName(type.FullName ?? typeName, type, configuration),
2628
type.IsEnum,
29+
type.IsGenericType,
30+
type.IsGenericType ? ((TypeInfo)type).GenericTypeParameters.Select(x => GetDestinationType(x, [], false, TypeChecks.IsNullable(x))).ToList() : [],
2731
type.IsEnum ? null : GetProperties(type).Distinct().ToList(),
2832
type.IsEnum ? GetEnumProperties(type) : null
2933
);
@@ -71,7 +75,19 @@ private List<OutputProperty> GetProperties(Type type)
7175

7276
var destinationName = GetDestinationName(property.Name);
7377
var destinationType = GetDestinationType(property.PropertyType, property.CustomAttributes, isReadonly, TypeChecks.IsNullable(property.PropertyType));
74-
var outputProperty = new OutputProperty(property.Name, property.PropertyType, destinationType.InnerType, destinationName, destinationType.TypeName, destinationType.ImportType, destinationType.IsBuiltin, destinationType.IsArray, TypeChecks.IsNullable(property), destinationType.IsReadonly);
78+
var outputProperty = new OutputProperty(
79+
property.Name,
80+
property.PropertyType,
81+
destinationType.InnerType,
82+
destinationName,
83+
destinationType.TypeName,
84+
destinationType.ImportType,
85+
destinationType.IsBuiltin,
86+
destinationType.IsArray,
87+
TypeChecks.IsNullable(property),
88+
destinationType.IsReadonly,
89+
destinationType.IsGeneric,
90+
destinationType.GenericTypeArguments);
7591

7692
var obsolete = property.CustomAttributes.FirstOrDefault(x => x.AttributeType.FullName == "System.ObsoleteAttribute");
7793
outputProperty.Obsolete = obsolete is not null ? new ObsoleteInfo((string?)obsolete.ConstructorArguments.FirstOrDefault().Value) : null;
@@ -94,11 +110,14 @@ private List<OutputProperty> GetProperties(Type type)
94110

95111
public DestinationType GetDestinationType(in Type sourceType, IEnumerable<CustomAttributeData> customAttributes, bool isReadonly, bool isNullable)
96112
{
97-
if (configuration.TypeMaps.TryGetValue(sourceType.FullName!, out var destType))
98-
return new DestinationType(destType.Replace("[]", string.Empty), sourceType.FullName, true, destType.Contains("[]"), isReadonly, isNullable || TypeChecks.IsNullable(sourceType), null);
113+
if (!sourceType.IsGenericParameter && configuration.TypeMaps.TryGetValue(sourceType.FullName!, out var destType))
114+
return new DestinationType(destType.Replace("[]", string.Empty), sourceType.FullName, true, destType.Contains("[]"), isReadonly, isNullable || TypeChecks.IsNullable(sourceType), false, [], null, sourceType);
99115

100116
if (CustomMappedTypes.TryGetValue(sourceType, out var customType))
101-
return new DestinationType(customType.Name, customType.FullName, false, false, isReadonly, TypeChecks.IsNullable(sourceType), null);
117+
return new DestinationType(customType.Name, customType.FullName, false, false, isReadonly, TypeChecks.IsNullable(sourceType), customType.IsGeneric, customType.GenericTypeArguments, null, customType.ContractedType.Type);
118+
119+
if (sourceType.IsGenericTypeParameter)
120+
return new DestinationType(sourceType.Name, null, true, false, false, isNullable, true, [], null, sourceType, "");
102121

103122
if (TypeChecks.ImplementsIDictionary(sourceType))
104123
{
@@ -108,15 +127,15 @@ public DestinationType GetDestinationType(in Type sourceType, IEnumerable<Custom
108127

109128
var isBuiltin = keyType.IsBuiltin && valueDestinationType.IsBuiltin;
110129

111-
return new DestinationType($"{{ [key: {keyType.TypeName}]: {valueDestinationType.FullTypeName} }}", valueDestinationType.FullName, isBuiltin, false, isReadonly, valueDestinationType.IsNullable, valueType, valueDestinationType.ImportType);
130+
return new DestinationType($"{{ [key: {keyType.TypeName}]: {valueDestinationType.FullTypeName} }}", valueDestinationType.FullName, isBuiltin, false, isReadonly, valueDestinationType.IsNullable, valueDestinationType.IsGeneric, valueDestinationType.GenericTypeArguments, valueType, valueDestinationType.SourceType, valueDestinationType.ImportType);
112131
}
113132

114133
if (TypeChecks.ImplementsIEnumerable(sourceType))
115134
{
116135
var innerType = TypeChecks.GetGenericType(sourceType);
117136

118-
var (TypeName, FullName, _, IsBuiltin, _, IsReadonly, IsNullable, _) = GetDestinationType(innerType, customAttributes, isReadonly, isNullable);
119-
return new DestinationType(TypeName, FullName, IsBuiltin, true, IsReadonly, IsNullable, innerType);
137+
var (TypeName, FullName, _, IsBuiltin, _, IsReadonly, IsNullable, IsGeneric, _, _, _) = GetDestinationType(innerType, customAttributes, isReadonly, isNullable);
138+
return new DestinationType(TypeName, FullName, IsBuiltin, true, IsReadonly, IsNullable, IsGeneric, [], innerType, sourceType);
120139
}
121140

122141
if (TypeChecks.IsValueTuple(sourceType))
@@ -128,21 +147,37 @@ public DestinationType GetDestinationType(in Type sourceType, IEnumerable<Custom
128147
var argumentList = argumentDestinationTypes.Select((arg, idx) => $"item{idx + 1}: {arg.FullTypeName}");
129148
var typeName = $"{{ {string.Join(", ", argumentList)} }}";
130149

131-
return new DestinationType(typeName, sourceType.FullName, isBuiltin, false, isReadonly, false, null);
150+
return new DestinationType(typeName, sourceType.FullName, isBuiltin, false, isReadonly, false, false, [], null, sourceType);
132151
}
133152

134153
if (TypeChecks.IsNullable(sourceType))
135154
{
136155
return GetDestinationType(sourceType.GenericTypeArguments.First(), customAttributes, isReadonly, true);
137156
}
138157

158+
if (sourceType.IsGenericType && sourceType.GenericTypeArguments.Length > 0)
159+
{
160+
var genericType = sourceType.GetGenericTypeDefinition();
161+
var genericOutputType = Convert(genericType);
162+
CustomMappedTypes.TryAdd(genericType, genericOutputType);
163+
164+
var genericArguments = sourceType.GenericTypeArguments
165+
.Select(x => GetDestinationType(x, customAttributes, isReadonly, TypeChecks.IsNullable(x)))
166+
.ToList();
167+
168+
var importType = genericOutputType.Name.Split('`').First();
169+
var typeName = importType + $"<{string.Join(", ", genericArguments.Select(x => x.TypeName))}>";
170+
171+
return new DestinationType(typeName, genericOutputType.FullName, false, false, isReadonly, isNullable, true, genericArguments, null, genericOutputType.ContractedType.Type, importType);
172+
}
173+
139174
if (customAttributes.Any(x => x.AttributeType.FullName == "System.Runtime.CompilerServices.DynamicAttribute"))
140-
return new DestinationType(DestinationTypes.Dynamic, null, true, false, isReadonly, true, null);
175+
return new DestinationType(DestinationTypes.Dynamic, null, true, false, isReadonly, true, false, [], null, null);
141176

142177
// FIXME: Check if this is one of our types?
143178
var outputType = Convert(sourceType);
144179
CustomMappedTypes.Add(sourceType, outputType);
145-
return new DestinationType(outputType.Name, outputType.FullName, false, false, isReadonly, isNullable || TypeChecks.IsNullable(sourceType), null);
180+
return new DestinationType(outputType.Name, outputType.FullName, false, false, isReadonly, isNullable || TypeChecks.IsNullable(sourceType), outputType.IsGeneric, outputType.GenericTypeArguments, null, sourceType);
146181

147182
// throw new ArgumentException($"Unexpected type: {sourceType}");
148183
}

TypeContractor/TypeScript/TypeScriptWriter.cs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,25 @@ private void BuildHeader()
4848

4949
private void BuildImports(OutputType type, IEnumerable<OutputType> allTypes, bool buildZodSchema)
5050
{
51-
var properties = type.Properties ?? Enumerable.Empty<OutputProperty>();
51+
var properties = type.Properties ?? [];
5252
var imports = properties
5353
.Where(p => !p.IsBuiltin)
5454
.DistinctBy(p => p.InnerSourceType ?? p.SourceType)
5555
.ToList();
5656

57+
foreach (var property in properties)
58+
{
59+
if (!property.IsGeneric) continue;
60+
if (property.GenericTypeArguments.Count == 0) continue;
61+
62+
foreach (var genArg in property.GenericTypeArguments)
63+
{
64+
if (genArg.IsBuiltin) continue;
65+
if (genArg.InnerType is null && genArg.SourceType is null) continue;
66+
imports.Add(new OutputProperty(genArg.TypeName, (genArg.InnerType ?? genArg.SourceType)!, null, "", genArg.TypeName, genArg.ImportType, false, genArg.IsArray, genArg.IsNullable, genArg.IsReadonly, genArg.IsGeneric, genArg.GenericTypeArguments));
67+
}
68+
}
69+
5770
if (buildZodSchema)
5871
_builder.AppendLine(ZodSchemaWriter.LibraryImport);
5972

@@ -117,7 +130,14 @@ private void BuildExport(OutputType type)
117130
}
118131
else
119132
{
120-
_builder.AppendLine($"export interface {type.Name} {{");
133+
var genericPropertyTypes = type.IsGeneric
134+
? type.GenericTypeArguments ?? []
135+
: [];
136+
var genericTypeArguments = genericPropertyTypes.Count > 0
137+
? $"<{string.Join(", ", genericPropertyTypes.Select(x => x.TypeName))}>"
138+
: "";
139+
140+
_builder.AppendLine($"export interface {type.Name}{genericTypeArguments} {{");
121141
}
122142

123143
// Body
@@ -168,6 +188,11 @@ private static List<OutputType> GetImportedTypes(IEnumerable<OutputType> allType
168188
return allTypes.Where(x => x.FullName == keyType.FullName || x.FullName == valueType.FullName).ToList();
169189
}
170190

191+
if (import.IsGeneric && import.GenericTypeArguments.Count > 0)
192+
return allTypes
193+
.Where(x => x.FullName == $"{sourceType.Namespace}.{sourceType.Name}")
194+
.ToList();
195+
171196
return allTypes.Where(x => x.FullName == sourceType.FullName).ToList();
172197
}
173198
}

0 commit comments

Comments
 (0)