diff --git a/src/StructId.FunctionalTests/Functional.cs b/src/StructId.FunctionalTests/Functional.cs
index ac5aa0e..5b1013b 100644
--- a/src/StructId.FunctionalTests/Functional.cs
+++ b/src/StructId.FunctionalTests/Functional.cs
@@ -24,6 +24,19 @@ public record User(UserId Id, string Name, Wallet Wallet);
public class FunctionalTests(ITestOutputHelper output)
{
+ [Fact]
+ public void TypeConverters()
+ {
+ var id = ProductId.New();
+ var converter = TypeDescriptor.GetConverter(id);
+
+ Assert.True(converter.CanConvertTo(typeof(string)));
+ Assert.True(converter.CanConvertFrom(typeof(string)));
+
+ var id2 = (ProductId?)converter.ConvertFromString(converter.ConvertToString(id)!);
+ Assert.Equal(id, id2);
+ }
+
[Fact]
public void JsonConversion()
{
diff --git a/src/StructId.FunctionalTests/UlidTests.cs b/src/StructId.FunctionalTests/UlidTests.cs
index 395447f..3a27d62 100644
--- a/src/StructId.FunctionalTests/UlidTests.cs
+++ b/src/StructId.FunctionalTests/UlidTests.cs
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
+using System.ComponentModel;
using System.Data;
using System.Text.Json;
using Dapper;
@@ -69,6 +70,19 @@ public UlidToStringConverter(ConverterMappingHints? mappingHints = null)
public class UlidTests
{
+ [Fact]
+ public void TypeConverters()
+ {
+ var id = UlidId.New();
+ var converter = TypeDescriptor.GetConverter(id);
+
+ Assert.True(converter.CanConvertTo(typeof(string)));
+ Assert.True(converter.CanConvertFrom(typeof(string)));
+
+ var id2 = (UlidId?)converter.ConvertFromString(converter.ConvertToString(id)!);
+ Assert.Equal(id, id2);
+ }
+
[Fact]
public void JsonConversion()
{
diff --git a/src/StructId/Templates/TypeConverter.cs b/src/StructId/Templates/TypeConverter.cs
new file mode 100644
index 0000000..eb5e0b6
--- /dev/null
+++ b/src/StructId/Templates/TypeConverter.cs
@@ -0,0 +1,55 @@
+//
+#nullable enable
+
+using System;
+using System.ComponentModel;
+using System.Globalization;
+using StructId;
+
+[TStructId]
+[TypeConverter(typeof(TSelf.StringConverter))]
+file readonly partial record struct TSelf(string Value)
+{
+ partial class StringConverter : TypeConverter
+ {
+ ///
+ public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
+ => sourceType == typeof(string) || sourceType == typeof(TSelf);
+
+ ///
+ public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
+ {
+ if (value == null)
+ return default(TSelf);
+
+ if (value is string typedValue)
+ return TSelf.New(typedValue);
+
+ throw new ArgumentException($"Cannot convert '{value}' to {nameof(TSelf)}", nameof(value));
+ }
+
+ ///
+ public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType)
+ => destinationType == typeof(string) || destinationType == typeof(TSelf);
+
+ ///
+ public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
+ {
+ if (value != null)
+ {
+ if (destinationType == typeof(string))
+ return ((TSelf)value).Value;
+
+ if (destinationType == typeof(TSelf))
+ return value;
+ }
+
+ throw new InvalidOperationException($"Cannot convert '{value}' to '{destinationType}'");
+ }
+ }
+}
+
+file partial record struct TSelf : INewable
+{
+ public static TSelf New(string value) => throw new NotImplementedException();
+}
\ No newline at end of file
diff --git a/src/StructId/Templates/TypeConverterT.cs b/src/StructId/Templates/TypeConverterT.cs
new file mode 100644
index 0000000..593edf7
--- /dev/null
+++ b/src/StructId/Templates/TypeConverterT.cs
@@ -0,0 +1,64 @@
+//
+#nullable enable
+
+using System;
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using StructId;
+
+[TStructId]
+[TypeConverter(typeof(TSelf.StringConverter))]
+file readonly partial record struct TSelf(/*!string*/ TValue Value)
+{
+ partial class StringConverter : TypeConverter
+ {
+ ///
+ public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
+ => sourceType == typeof(string) || sourceType == typeof(TSelf);
+
+ ///
+ public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
+ {
+ if (value == null)
+ return default(TSelf);
+
+ if (value is string typedValue)
+ return TSelf.New(TValue.Parse(typedValue, culture));
+
+ throw new ArgumentException($"Cannot convert '{value}' to {nameof(TSelf)}", nameof(value));
+ }
+
+ ///
+ public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType)
+ => destinationType == typeof(string) || destinationType == typeof(TSelf);
+
+ ///
+ public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
+ {
+ if (value != null)
+ {
+ if (destinationType == typeof(string))
+ return ((TSelf)value).Value.ToString(null, culture);
+
+ if (destinationType == typeof(TSelf))
+ return value;
+ }
+
+ throw new InvalidOperationException($"Cannot convert '{value}' to '{destinationType}'");
+ }
+ }
+}
+
+file partial record struct TSelf : INewable
+{
+ public static TSelf New(TValue value) => throw new NotImplementedException();
+}
+
+// This will be removed when applying the template to each user-defined struct id.
+file struct TValue : IParsable, IFormattable
+{
+ public static TValue Parse(string s, IFormatProvider? provider) => throw new NotImplementedException();
+ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out TValue result) => throw new NotImplementedException();
+ public string ToString(string? format, IFormatProvider? formatProvider) => throw new NotImplementedException();
+}
\ No newline at end of file