The sunken ship that is the JsonStringEnumConverter

A sunken oil tanker on the shores of a beach.

Back in 2018, a discussion started asking for .NET runtime provided JSON serializing and deserializing support.

Around that same time, the original author of Newtonsoft.Json, James Newton-King, joined Microsoft and became one of the principal developers of what later became System.Text.Json. It did not take long for people to start asking for additional features such as adding support for System.Runtime.Serialization attributes within System.Text.Json.

Which, concidently, also happened to be around the same time that the original implementation of JsonStringEnumConverter was first written.

As predicted by James, requests soon started appearing asking for support to customize the enum member names.

After a few years of people coming up with workarounds and hoping that the .NET team would eventually fix the issue, the discussion was closed and led me to reopen the issue again in 2022, once again hoping that the work started with JsonStringEnumConverter would continue and the workarounds could finally be put to rest.

According to the few comments left by Eirik Tsarpalis, who appears to have taken over this portion of the runtime, it would seem that the JsonStringEnumConverter has become a sunken ship that will be relegated the archives of its namespace and forgotten about as the .NET team prioritizes other work.

So where does that leave us?

I have two alternatives to offer you. Extension methods or a better JsonConverter. Pick whichever works best for your use case.

Extension methods

A quick and dirty workaround is to use extension methods like these:

using System.Text.Json.Serialization;

public static class EnumExtensions
{
    public static string ToEnumString<TField>(this TField field)
        where TField : Enum
    {
        var fieldInfo = typeof(TField).GetField(field.ToString());
        if (fieldInfo is null)
            throw new InvalidOperationException($"Field {nameof(field)} was not found.");

        var attributes = (JsonPropertyNameAttribute[])fieldInfo.GetCustomAttributes(typeof(JsonPropertyNameAttribute), false);
        if (attributes.Length == 0)
            throw new NotImplementedException($"The field has not been annotated with a {nameof(JsonPropertyNameAttribute)}.");

        var name = attributes[0]
            .Name;
        if (name is null)
            throw new NotImplementedException($"{nameof(JsonPropertyNameAttribute)}.{nameof(JsonPropertyNameAttribute.Name)} has not been set for this field.");

        return name;
    }

    public static TField FromEnumString<TField>(this string str)
        where TField : Enum
    {
        var fields = typeof(TField).GetFields();
        foreach (var field in fields)
        {
            var attributes = (JsonPropertyNameAttribute[])field.GetCustomAttributes(typeof(JsonPropertyNameAttribute), false);
            if (attributes.Length == 0)
                continue;

            var name = attributes[0]
                .Name;
            if (name is null)
                throw new NotImplementedException($"{nameof(JsonPropertyNameAttribute)}.{nameof(JsonPropertyNameAttribute.Name)} has not been set for the field {field.Name}.");

            if (string.Equals(name, str, StringComparison.OrdinalIgnoreCase))
                return (TField)Enum.Parse(typeof(TField), field.Name) ?? throw new ArgumentNullException(field.Name);
        }

        throw new InvalidOperationException($"'{str}' was not found in enum {typeof(TField).Name}.");
    }
}

After decorating an enum with JsonPropertyNameAttribute like this:

public enum ContactType
{
    [JsonPropertyNameAttribute(Name = "per")]
    Person,

    [JsonPropertyNameAttribute(Name = "bus")]
    Business
}

You can then use it the extension methods like this:

// Converts "bus" to ContactType.Business.
var asEnum = "bus".FromEnumString<ContactType>();

// Converts ContactType.Person to "per".
var asString = ContactType.Person.ToEnumString();

Though this works in simple scenarios, it can quickly become complicated for larged models and more complex use cases. Which is why I prefer using my improved JsonConverter as shown below.

A better JsonConverter

As detailed in my GitHub repository comparing different JsonConverters, my variation brings a lot of improvements not found in the runtime provided JsonStringEnumConverter.

  • Case-Insensitive Deserialization: Supports deserialization of enums regardless of the case of the JSON string.
  • Custom String Values: Supports JsonPropertyNameAttribute for custom enum string values.
  • Dual Value Handling: Can deserialize both string and integer values.
  • Naming Policy Integration: Integrates with PropertyNamingPolicy, making it adaptable to different naming conventions.
  • Detailed Error Messages: Provides comprehensive error information for easier debugging.
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;

public class BetterEnumConverter<TEnum> : JsonConverter<TEnum>
    where TEnum : struct, Enum
{
    private readonly Dictionary<TEnum, string> _enumToString = new();
    private readonly Dictionary<int, TEnum> _numberToEnum = new();
    private readonly Dictionary<string, TEnum> _stringToEnum = new(StringComparer.InvariantCultureIgnoreCase);

    public BetterEnumConverter(JsonSerializerOptions options)
    {
        var type = typeof(TEnum);
        var names = Enum.GetNames<TEnum>();
        var values = Enum.GetValues<TEnum>();
        var underlying = Enum.GetValuesAsUnderlyingType<TEnum>().Cast<int>().ToArray();
        for (var index = 0; index < names.Length; index++)
        {
            var name = names[index];
            var value = values[index];
            var underlyingValue = underlying[index];

            var attribute = type.GetMember(name)[0]
                .GetCustomAttributes(typeof(JsonPropertyNameAttribute), false)
                .Cast<JsonPropertyNameAttribute>()
                .FirstOrDefault();

            var defaultStringValue = FormatName(name, options);
            var customStringValue = attribute?.Name;

            _enumToString.TryAdd(value, customStringValue ?? defaultStringValue);
            _stringToEnum.TryAdd(defaultStringValue, value);
            if (customStringValue is not null)
                _stringToEnum.TryAdd(customStringValue, value);
            _numberToEnum.TryAdd(underlyingValue, value);
        }
    }

    private static string FormatName(string name, JsonSerializerOptions options)
    {
        return options.PropertyNamingPolicy?.ConvertName(name) ?? name;
    }

    public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        switch (reader.TokenType)
        {
            case JsonTokenType.String:
            {
                var stringValue = reader.GetString();

                if (stringValue is not null && _stringToEnum.TryGetValue(stringValue, out var enumValue))
                    return enumValue;
                break;
            }
            case JsonTokenType.Number:
            {
                if (reader.TryGetInt32(out var numValue) && _numberToEnum.TryGetValue(numValue, out var enumValue))
                    return enumValue;
                break;
            }
        }

        throw new JsonException(
            $"The JSON value '{
                Encoding.UTF8.GetString(reader.ValueSpan)
            }' could not be converted to {typeof(TEnum).FullName}. BytePosition: {reader.BytesConsumed}."
        );
    }

    public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
    {
        writer.WriteStringValue(_enumToString[value]);
    }
}

To use it, you'd simply add it to the JsonSerializerOptions of the Serialize and Deserialize methods.

var options = new JsonSerializerOptions
{
    Converters = { new BetterEnumConverter() },
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

string json = JsonSerializer.Serialize(obj, options);

Leave me a comment below or find me online and let me know what you think!