Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions NewType.Generator/AliasAttributeSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ internal enum NewtypeOptions
NoImplicitUnwrap = 2,
/// <summary>Suppress forwarding constructors from T.</summary>
NoConstructorForwarding = 4,
/// <summary>Emit System.Text.Json and TypeConverter serialization support.</summary>
Serializable = 8,
/// <summary>Suppress both implicit conversions.</summary>
NoImplicitConversions = NoImplicitWrap | NoImplicitUnwrap,
/// <summary>Suppress implicit conversions and constructor forwarding.</summary>
Expand Down
98 changes: 98 additions & 0 deletions NewType.Generator/AliasCodeGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public string Generate()
AppendToString();
if (!_model.IsRecord)
AppendGetHashCode();
AppendSerializationConverters();

AppendTypeClose();
AppendNamespaceClose();
Expand Down Expand Up @@ -118,6 +119,12 @@ private void AppendTypeOpen()
(true, false) => "class",
(true, true) => "record class",
};
if (_model.EmitSerialization)
{
_sb.AppendLine($"{indent}[global::System.ComponentModel.TypeConverter(typeof({_model.TypeName}.NewtypeTypeConverter))]");
_sb.AppendLine($"{indent}[global::System.Text.Json.Serialization.JsonConverter(typeof({_model.TypeName}.NewtypeJsonConverter))]");
}

_sb.AppendLine($"{indent}{accessMod}{readonlyMod}partial {typeKeyword} {_model.TypeName} : {interfaceList}");
_sb.AppendLine($"{indent}{{");
}
Expand Down Expand Up @@ -305,6 +312,21 @@ private void AppendUnaryOperators()
? _model.TypeName
: op.ReturnTypeFullName;

// ++ and -- mutate their operand; _value is readonly, so increment a local copy
// instead of the field directly (which would be CS0191). This path is reached for
// types that define op_Increment/op_Decrement as real operators (e.g. decimal).
if (op.Name is "op_Increment" or "op_Decrement")
{
AppendMethodImplAttribute(indent);
_sb.AppendLine($"{indent}public static {returnTypeStr} operator {opSymbol}({_model.TypeName} value)");
_sb.AppendLine($"{indent}{{");
_sb.AppendLine($"{indent} var v = value._value;");
_sb.AppendLine($"{indent} return {WrapIfAlias(op.ReturnIsAliasedType, $"{opSymbol}v")};");
_sb.AppendLine($"{indent}}}");
_sb.AppendLine();
continue;
}

var expr = WrapIfAlias(op.ReturnIsAliasedType, $"{opSymbol}value._value");
AppendMethodImplAttribute(indent);
_sb.AppendLine($"{indent}public static {returnTypeStr} operator {opSymbol}({_model.TypeName} value) => {expr};");
Expand Down Expand Up @@ -661,6 +683,82 @@ private void AppendGetHashCode()
_sb.AppendLine();
}

/// <summary>
/// Emits a nested TypeConverter and System.Text.Json converter when serialization is opted in.
/// Both delegate the underlying value to the aliased type's own serializer and construct a fresh
/// instance, so they work for every newtype form including readonly structs.
/// </summary>
private void AppendSerializationConverters()
{
if (!_model.EmitSerialization)
return;

var indent = GetMemberIndent();
var inner = indent + " ";
var body = indent + " ";
var t = _model.AliasedTypeFullName;
var name = _model.TypeName;
var minimal = _model.AliasedTypeMinimalName;

_sb.AppendLine($"{indent}#region Serialization");
_sb.AppendLine();

// TypeConverter — covers Newtonsoft.Json, ASP.NET model binding, configuration binding, etc.
_sb.AppendLine($"{indent}/// <summary>Converts {name} to and from {minimal} and string.</summary>");
_sb.AppendLine($"{indent}public sealed class NewtypeTypeConverter : global::System.ComponentModel.TypeConverter");
_sb.AppendLine($"{indent}{{");
_sb.AppendLine($"{inner}/// <inheritdoc/>");
_sb.AppendLine($"{inner}public override bool CanConvertFrom(global::System.ComponentModel.ITypeDescriptorContext? context, global::System.Type sourceType)");
_sb.AppendLine($"{body}=> sourceType == typeof(string) || sourceType == typeof({t}) || base.CanConvertFrom(context, sourceType);");
_sb.AppendLine();
_sb.AppendLine($"{inner}/// <inheritdoc/>");
_sb.AppendLine($"{inner}public override bool CanConvertTo(global::System.ComponentModel.ITypeDescriptorContext? context, global::System.Type? destinationType)");
_sb.AppendLine($"{body}=> destinationType == typeof(string) || destinationType == typeof({t}) || base.CanConvertTo(context, destinationType);");
_sb.AppendLine();
_sb.AppendLine($"{inner}/// <inheritdoc/>");
_sb.AppendLine($"{inner}public override object? ConvertFrom(global::System.ComponentModel.ITypeDescriptorContext? context, global::System.Globalization.CultureInfo? culture, object value)");
_sb.AppendLine($"{inner}{{");
_sb.AppendLine($"{body}if (value is {t} unwrapped) return new {name}(unwrapped);");
_sb.AppendLine($"{body}if (value is string text)");
_sb.AppendLine($"{body}{{");
_sb.AppendLine($"{body} var converted = global::System.ComponentModel.TypeDescriptor.GetConverter(typeof({t})).ConvertFromString(context, culture, text);");
_sb.AppendLine($"{body} if (converted is null) return null;");
_sb.AppendLine($"{body} return new {name}(({t})converted);");
_sb.AppendLine($"{body}}}");
_sb.AppendLine($"{body}return base.ConvertFrom(context, culture, value);");
_sb.AppendLine($"{inner}}}");
_sb.AppendLine();
_sb.AppendLine($"{inner}/// <inheritdoc/>");
_sb.AppendLine($"{inner}public override object? ConvertTo(global::System.ComponentModel.ITypeDescriptorContext? context, global::System.Globalization.CultureInfo? culture, object? value, global::System.Type destinationType)");
_sb.AppendLine($"{inner}{{");
_sb.AppendLine($"{body}if (value is {name} wrapped)");
_sb.AppendLine($"{body}{{");
_sb.AppendLine($"{body} if (destinationType == typeof({t})) return wrapped._value;");
_sb.AppendLine($"{body} if (destinationType == typeof(string)) return global::System.ComponentModel.TypeDescriptor.GetConverter(typeof({t})).ConvertToString(context, culture, wrapped._value);");
_sb.AppendLine($"{body}}}");
_sb.AppendLine($"{body}return base.ConvertTo(context, culture, value, destinationType);");
_sb.AppendLine($"{inner}}}");
_sb.AppendLine($"{indent}}}");
_sb.AppendLine();

// System.Text.Json converter — serializes as the underlying value.
_sb.AppendLine($"{indent}/// <summary>Serializes {name} as its underlying {minimal} for System.Text.Json.</summary>");
_sb.AppendLine($"{indent}public sealed class NewtypeJsonConverter : global::System.Text.Json.Serialization.JsonConverter<{name}>");
_sb.AppendLine($"{indent}{{");
_sb.AppendLine($"{inner}/// <inheritdoc/>");
_sb.AppendLine($"{inner}public override {name} Read(ref global::System.Text.Json.Utf8JsonReader reader, global::System.Type typeToConvert, global::System.Text.Json.JsonSerializerOptions options)");
_sb.AppendLine($"{body}=> new {name}(global::System.Text.Json.JsonSerializer.Deserialize<{t}>(ref reader, options)!);");
_sb.AppendLine();
_sb.AppendLine($"{inner}/// <inheritdoc/>");
_sb.AppendLine($"{inner}public override void Write(global::System.Text.Json.Utf8JsonWriter writer, {name} value, global::System.Text.Json.JsonSerializerOptions options)");
_sb.AppendLine($"{body}=> global::System.Text.Json.JsonSerializer.Serialize(writer, value._value, options);");
_sb.AppendLine($"{indent}}}");
_sb.AppendLine();

_sb.AppendLine($"{indent}#endregion");
_sb.AppendLine();
}

private void AppendForwardedConstructor(string indent, ConstructorInfo ctor)
{
var parameters = FormatConstructorParameters(ctor);
Expand Down
1 change: 1 addition & 0 deletions NewType.Generator/AliasModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ internal readonly record struct AliasModel(
bool SuppressImplicitWrap,
bool SuppressImplicitUnwrap,
bool SuppressConstructorForwarding,
bool EmitSerialization,
int MethodImplValue,

// Members
Expand Down
2 changes: 2 additions & 0 deletions NewType.Generator/AliasModelExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ internal static class AliasModelExtractor
private const int OptionsNoImplicitWrap = 1;
private const int OptionsNoImplicitUnwrap = 2;
private const int OptionsNoConstructorForwarding = 4;
private const int OptionsSerializable = 8;

public static AliasModel? Extract(
GeneratorAttributeSyntaxContext context,
Expand Down Expand Up @@ -76,6 +77,7 @@ internal static class AliasModelExtractor
SuppressImplicitWrap: (options & OptionsNoImplicitWrap) != 0,
SuppressImplicitUnwrap: (options & OptionsNoImplicitUnwrap) != 0,
SuppressConstructorForwarding: (options & OptionsNoConstructorForwarding) != 0,
EmitSerialization: (options & OptionsSerializable) != 0,
MethodImplValue: methodImpl,
BinaryOperators: binaryOperators,
UnaryOperators: unaryOperators,
Expand Down
22 changes: 22 additions & 0 deletions NewType.Generator/NUGET.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,27 @@ public readonly partial struct RelaxedId;
| `NoConstructorForwarding` | Suppress forwarded constructors from `T` |
| `NoImplicitConversions` | `NoImplicitWrap \| NoImplicitUnwrap` |
| `Opaque` | `NoImplicitConversions \| NoConstructorForwarding` |
| `Serializable` | Generate `System.Text.Json` + `TypeConverter` serialization support |

With any option, the primary constructor (`new Alias(T value)`) and `.Value` property are always available.

## Serialization

By default a newtype serializes like any struct — which usually isn't what you want. Opt in with `NewtypeOptions.Serializable` to make it serialize as its underlying value:

```csharp
[newtype<string>(Options = NewtypeOptions.Serializable)]
public readonly partial struct ContractId;
```

```csharp
JsonSerializer.Serialize(new ContractId("CTR-002")); // "CTR-002"
```

This generates a `[JsonConverter]` (for `System.Text.Json`) and a `[TypeConverter]` (for `Newtonsoft.Json`, ASP.NET model binding, configuration binding, dictionary keys, etc.). Both delegate the underlying value to `T`'s own serializer, so any serializable `T` works.

> `XmlSerializer` round-tripping is not supported for `readonly struct` newtypes — it populates instances in place, which an immutable struct forbids.

## Extending Your Types

Since the alias is a `partial` type, you can add custom members:
Expand All @@ -129,6 +147,10 @@ Enable generated file output in your project:

Then inspect `obj/Debug/net10.0/GeneratedFiles/` after building.

## Naming

The attribute is intentionally lowercase `newtype<T>`: it mirrors the `newtype` namespace, the NuGet package id, and Haskell's `newtype` keyword. The class itself keeps the conventional `Attribute` suffix internally (`newtypeAttribute<T>`), so both `[newtype<T>]` and the `[newtype(typeof(T))]` fallback bind correctly.

## Requirements

- .NET 8+ SDK
Expand Down
71 changes: 71 additions & 0 deletions NewType.Tests/GeneratorTests/GeneratorOutputTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,75 @@ public void NonGeneric_Attribute_Works()
Assert.Contains("private readonly int _value;", text);
Assert.Contains("public TestId(int value)", text);
}

[Fact]
public void Generates_Output_For_Decimal_Alias_WithoutReadonlyFieldError()
{
// Issue #5: decimal defines op_Increment/op_Decrement as real operators, so the generated
// ++/-- must increment a local copy rather than assign the readonly _value field (CS0191).
// RunGenerator asserts the output compiles without errors or warnings.
const string source = """
using newtype;

[newtype<decimal>]
public readonly partial struct Money;
""";

var result = GeneratorTestHelper.RunGenerator(source);

var text = result.Results[0].GeneratedSources
.Single(s => s.HintName.EndsWith("Money.g.cs"))
.SourceText.ToString();

Assert.Contains("operator ++(Money value)", text);
Assert.Contains("operator --(Money value)", text);
Assert.Contains("var v = value._value;", text);
// The buggy form that mutated the readonly field must not appear.
Assert.DoesNotContain("++value._value", text);
Assert.DoesNotContain("--value._value", text);
}

[Fact]
public void Generates_Serialization_Support_When_Opted_In()
{
// Issue #4
const string source = """
using newtype;

[newtype<string>(Options = NewtypeOptions.Serializable)]
public readonly partial struct ContractId;
""";

var result = GeneratorTestHelper.RunGenerator(source);

var text = result.Results[0].GeneratedSources
.Single(s => s.HintName.EndsWith("ContractId.g.cs"))
.SourceText.ToString();

Assert.Contains("[global::System.ComponentModel.TypeConverter(typeof(ContractId.NewtypeTypeConverter))]", text);
Assert.Contains("[global::System.Text.Json.Serialization.JsonConverter(typeof(ContractId.NewtypeJsonConverter))]", text);
Assert.Contains("public sealed class NewtypeTypeConverter", text);
Assert.Contains("public sealed class NewtypeJsonConverter", text);
}

[Fact]
public void No_Serialization_Support_By_Default()
{
const string source = """
using newtype;

[newtype<string>]
public readonly partial struct PlainId;
""";

var result = GeneratorTestHelper.RunGenerator(source);

var text = result.Results[0].GeneratedSources
.Single(s => s.HintName.EndsWith("PlainId.g.cs"))
.SourceText.ToString();

Assert.DoesNotContain("NewtypeJsonConverter", text);
Assert.DoesNotContain("NewtypeTypeConverter", text);
Assert.DoesNotContain("System.Text.Json", text);
}
}
61 changes: 61 additions & 0 deletions NewType.Tests/PrimitiveTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,67 @@ public void IsActive_ToString()
Assert.Equal("False", b.ToString());
}

// --- Amount (decimal) — issue #5 regression: ++/-- must not assign the readonly field ---

[Fact]
public void Amount_ImplicitConversion_RoundTrip()
{
Amount a = 5.5m;
decimal raw = a;
Assert.Equal(5.5m, raw);
}

[Fact]
public void Amount_Increment()
{
Amount a = new(5m);
a++;
Assert.Equal(6m, a.Value);
++a;
Assert.Equal(7m, a.Value);
}

[Fact]
public void Amount_Decrement()
{
Amount a = new(5m);
a--;
Assert.Equal(4m, a.Value);
--a;
Assert.Equal(3m, a.Value);
}

[Fact]
public void Amount_Arithmetic()
{
Amount a = 10m;
Amount b = 3m;

Assert.Equal(13m, (decimal) (a + b));
Assert.Equal(7m, (decimal) (a - b));
Assert.Equal(30m, (decimal) (a * b));
}

[Fact]
public void Amount_UnaryOperators()
{
Amount a = 5m;
Assert.Equal(-5m, (decimal) (-a));
Assert.Equal(5m, (decimal) (+a));
}

[Fact]
public void Amount_Comparison()
{
Amount low = 1m;
Amount high = 100m;

Assert.True(low < high);
Assert.True(high > low);
Assert.True(low <= high);
Assert.True(high >= low);
}

// --- Ref / ECS patterns with primitives ---

[Fact]
Expand Down
Loading
Loading