diff --git a/NewType.Generator/AliasAttributeSource.cs b/NewType.Generator/AliasAttributeSource.cs
index debf22e..b66acbc 100644
--- a/NewType.Generator/AliasAttributeSource.cs
+++ b/NewType.Generator/AliasAttributeSource.cs
@@ -30,6 +30,8 @@ internal enum NewtypeOptions
NoImplicitUnwrap = 2,
/// Suppress forwarding constructors from T.
NoConstructorForwarding = 4,
+ /// Emit System.Text.Json and TypeConverter serialization support.
+ Serializable = 8,
/// Suppress both implicit conversions.
NoImplicitConversions = NoImplicitWrap | NoImplicitUnwrap,
/// Suppress implicit conversions and constructor forwarding.
diff --git a/NewType.Generator/AliasCodeGenerator.cs b/NewType.Generator/AliasCodeGenerator.cs
index cc75f93..c57b342 100644
--- a/NewType.Generator/AliasCodeGenerator.cs
+++ b/NewType.Generator/AliasCodeGenerator.cs
@@ -44,6 +44,7 @@ public string Generate()
AppendToString();
if (!_model.IsRecord)
AppendGetHashCode();
+ AppendSerializationConverters();
AppendTypeClose();
AppendNamespaceClose();
@@ -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}{{");
}
@@ -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};");
@@ -661,6 +683,82 @@ private void AppendGetHashCode()
_sb.AppendLine();
}
+ ///
+ /// 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.
+ ///
+ 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}/// Converts {name} to and from {minimal} and string.");
+ _sb.AppendLine($"{indent}public sealed class NewtypeTypeConverter : global::System.ComponentModel.TypeConverter");
+ _sb.AppendLine($"{indent}{{");
+ _sb.AppendLine($"{inner}/// ");
+ _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}/// ");
+ _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}/// ");
+ _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}/// ");
+ _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}/// Serializes {name} as its underlying {minimal} for System.Text.Json.");
+ _sb.AppendLine($"{indent}public sealed class NewtypeJsonConverter : global::System.Text.Json.Serialization.JsonConverter<{name}>");
+ _sb.AppendLine($"{indent}{{");
+ _sb.AppendLine($"{inner}/// ");
+ _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}/// ");
+ _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);
diff --git a/NewType.Generator/AliasModel.cs b/NewType.Generator/AliasModel.cs
index 2584186..0756cb3 100644
--- a/NewType.Generator/AliasModel.cs
+++ b/NewType.Generator/AliasModel.cs
@@ -40,6 +40,7 @@ internal readonly record struct AliasModel(
bool SuppressImplicitWrap,
bool SuppressImplicitUnwrap,
bool SuppressConstructorForwarding,
+ bool EmitSerialization,
int MethodImplValue,
// Members
diff --git a/NewType.Generator/AliasModelExtractor.cs b/NewType.Generator/AliasModelExtractor.cs
index da34af1..cefcb93 100644
--- a/NewType.Generator/AliasModelExtractor.cs
+++ b/NewType.Generator/AliasModelExtractor.cs
@@ -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,
@@ -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,
diff --git a/NewType.Generator/NUGET.md b/NewType.Generator/NUGET.md
index fccae62..e39099b 100644
--- a/NewType.Generator/NUGET.md
+++ b/NewType.Generator/NUGET.md
@@ -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(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:
@@ -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`: it mirrors the `newtype` namespace, the NuGet package id, and Haskell's `newtype` keyword. The class itself keeps the conventional `Attribute` suffix internally (`newtypeAttribute`), so both `[newtype]` and the `[newtype(typeof(T))]` fallback bind correctly.
+
## Requirements
- .NET 8+ SDK
diff --git a/NewType.Tests/GeneratorTests/GeneratorOutputTests.cs b/NewType.Tests/GeneratorTests/GeneratorOutputTests.cs
index 87102f7..5059466 100644
--- a/NewType.Tests/GeneratorTests/GeneratorOutputTests.cs
+++ b/NewType.Tests/GeneratorTests/GeneratorOutputTests.cs
@@ -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]
+ 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(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]
+ 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);
+ }
}
diff --git a/NewType.Tests/PrimitiveTests.cs b/NewType.Tests/PrimitiveTests.cs
index 90868e6..6cccfea 100644
--- a/NewType.Tests/PrimitiveTests.cs
+++ b/NewType.Tests/PrimitiveTests.cs
@@ -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]
diff --git a/NewType.Tests/SerializationTests.cs b/NewType.Tests/SerializationTests.cs
new file mode 100644
index 0000000..3ad8261
--- /dev/null
+++ b/NewType.Tests/SerializationTests.cs
@@ -0,0 +1,108 @@
+using System.ComponentModel;
+using System.Text.Json;
+using Xunit;
+
+namespace newtype.tests;
+
+///
+/// Issue #4 — newtypes opted into NewtypeOptions.Serializable round-trip through
+/// System.Text.Json (via a generated JsonConverter) and TypeConverter-based serializers.
+///
+public class SerializationTests
+{
+ // --- System.Text.Json ---
+
+ [Fact]
+ public void Json_String_SerializesAsBareValue()
+ {
+ var json = JsonSerializer.Serialize(new SerialId("CTR-002"));
+ Assert.Equal("\"CTR-002\"", json);
+ }
+
+ [Fact]
+ public void Json_String_RoundTrips()
+ {
+ var original = new SerialId("CTR-002");
+ var json = JsonSerializer.Serialize(original);
+ var restored = JsonSerializer.Deserialize(json);
+
+ Assert.Equal(original, restored);
+ Assert.Equal("CTR-002", restored.Value);
+ }
+
+ [Fact]
+ public void Json_Int_SerializesAsBareNumber()
+ {
+ var json = JsonSerializer.Serialize(new SerialCount(42));
+ Assert.Equal("42", json);
+
+ var restored = JsonSerializer.Deserialize(json);
+ Assert.Equal(42, restored.Value);
+ }
+
+ [Fact]
+ public void Json_AsPropertyOnContainingObject()
+ {
+ // Mirrors issue #4: a newtype used as a property serializes its underlying value
+ // rather than an empty element.
+ var order = new Order { Contract = new SerialId("CTR-002") };
+
+ var json = JsonSerializer.Serialize(order);
+ Assert.Equal("{\"Contract\":\"CTR-002\"}", json);
+
+ var restored = JsonSerializer.Deserialize(json);
+ Assert.NotNull(restored);
+ Assert.Equal("CTR-002", restored!.Contract.Value);
+ }
+
+ private sealed class Order
+ {
+ public SerialId Contract { get; set; }
+ }
+
+ // --- TypeConverter (Newtonsoft.Json, ASP.NET model binding, configuration binding, ...) ---
+
+ [Fact]
+ public void TypeConverter_IsDiscoverable()
+ {
+ var converter = TypeDescriptor.GetConverter(typeof(SerialId));
+ Assert.IsType(converter);
+ }
+
+ [Fact]
+ public void TypeConverter_String_RoundTrips()
+ {
+ var converter = TypeDescriptor.GetConverter(typeof(SerialId));
+
+ Assert.True(converter.CanConvertFrom(typeof(string)));
+ Assert.True(converter.CanConvertTo(typeof(string)));
+
+ var text = converter.ConvertToString(new SerialId("abc"));
+ Assert.Equal("abc", text);
+
+ var value = converter.ConvertFromString("abc");
+ Assert.Equal(new SerialId("abc"), value);
+ }
+
+ [Fact]
+ public void TypeConverter_Int_RoundTrips()
+ {
+ var converter = TypeDescriptor.GetConverter(typeof(SerialCount));
+
+ var text = converter.ConvertToString(new SerialCount(7));
+ Assert.Equal("7", text);
+
+ var value = converter.ConvertFromString("7");
+ Assert.Equal(new SerialCount(7), value);
+ }
+
+ [Fact]
+ public void NonSerializable_Newtype_HasNoCustomConverter()
+ {
+ // Name is a plain [newtype] without the Serializable flag, so no [TypeConverter]
+ // is generated and TypeDescriptor falls back to the default base converter. Any generated
+ // converter would derive from TypeConverter, so this exact-type check enforces the gate.
+ var converter = TypeDescriptor.GetConverter(typeof(Name));
+ Assert.IsType(converter);
+ }
+}
diff --git a/NewType.Tests/Types.cs b/NewType.Tests/Types.cs
index 7f3673a..6457a71 100644
--- a/NewType.Tests/Types.cs
+++ b/NewType.Tests/Types.cs
@@ -41,6 +41,17 @@ namespace newtype.tests;
[newtype]
public readonly partial struct Tint;
+// decimal — exercises forwarding of user-defined op_Increment/op_Decrement (issue #5)
+[newtype]
+public readonly partial struct Amount;
+
+// serialization opt-in — System.Text.Json + TypeConverter (issue #4)
+[newtype(Options = NewtypeOptions.Serializable)]
+public readonly partial struct SerialId;
+
+[newtype(Options = NewtypeOptions.Serializable)]
+public readonly partial struct SerialCount;
+
// record struct variants
[newtype]
public partial record struct Score;
diff --git a/README.md b/README.md
index 95af4f6..008ba3a 100644
--- a/README.md
+++ b/README.md
@@ -107,9 +107,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(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:
@@ -133,6 +151,10 @@ public readonly partial struct Position
Then inspect `obj/Debug/net10.0/GeneratedFiles/` after building.
+## Naming
+
+The attribute is intentionally lowercase `newtype`: it mirrors the `newtype` namespace, the NuGet package id, and Haskell's `newtype` keyword. The class itself keeps the conventional `Attribute` suffix internally (`newtypeAttribute`), so both `[newtype]` and the `[newtype(typeof(T))]` fallback bind correctly.
+
## Requirements
- .NET 8+ SDK