Skip to content

C sharp and JSON

  • At least as of mid 2024, the built-in System.Text.Json stuff seems pretty powerful.
  • The official Microsoft documentation is good (although sometimes long).
  • By default, fields aren’t serialized (reference), but properties are. Either make a property or annotate the field with [JsonInclude].
  • Note to self: I worked somewhat extensively with JSON stuff for Skeleseller, so if I’m ever looking for code in the future, check there. To anyone else reading this, the source is closed at the time of writing. 😢

Just use the C# defaults; no need for special attributes:

public class GameSaveData
{
public int NumLives { get; set; } = 5; // if "NumLives" doesn't exist in the JSON, it'll be loaded as 5
}

In Skeleseller, I had BaseAbility and then a ton of derived classes to control each individual ability: Fireball, QuickStrike, BasicAttack, etc. You can presumably add a bunch of [JsonDerivedType] annotations to BaseAbility if you want to specify each derived class, or you can use the contract model from the reference link to specify how to serialize each class. Here’s some code we wrote for Skeleseller:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Game.Src.Abilities.Base;
using Game.Src.Attributes;
using Game.Src.EntityComponentSystem;
using Game.Src.Types.Enums;
namespace Game.Src.SaveSystem;
/// <summary>
/// Handles polymorphic type resolution for JSON serialization. This code is only invoked during initialization, and
/// initialization only happens on the first save or load. <see
/// href="https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/polymorphism?pivots=dotnet-8-0"/>
/// </summary>
public class PolymorphicTypeResolver : DefaultJsonTypeInfoResolver
{
public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
{
JsonTypeInfo jsonTypeInfo = base.GetTypeInfo(type, options);
if (jsonTypeInfo.Type == typeof(BaseAbility))
{
SetUpAbilities(jsonTypeInfo);
}
else if (jsonTypeInfo.Type == typeof(Component))
{
SetUpComponents(jsonTypeInfo);
}
return jsonTypeInfo;
}
private void SetUpAbilities(JsonTypeInfo jsonTypeInfo)
{
jsonTypeInfo.PolymorphismOptions = new JsonPolymorphismOptions
{
IgnoreUnrecognizedTypeDiscriminators = true,
UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType,
};
foreach (AbilityId id in EnumFunctions.GetValidValues<AbilityId>())
{
BaseAbility ability = AllAbilities.CreateAbility(id);
jsonTypeInfo.PolymorphismOptions.DerivedTypes.Add(new JsonDerivedType(ability.GetType(), (int)id));
}
}
private void SetUpComponents(JsonTypeInfo jsonTypeInfo)
{
jsonTypeInfo.PolymorphismOptions = new JsonPolymorphismOptions
{
IgnoreUnrecognizedTypeDiscriminators = true,
UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType,
};
// This gets all concrete classes derived from Component that are serializable
IEnumerable<Type> serializableComponentTypes = Component
.GetAllComponentTypes()
.Where(Ecs.IsComponentTypeSerializable);
foreach (Type componentType in serializableComponentTypes)
{
ComponentId componentId = componentType.GetCustomAttribute<EcsComponentAttribute>()!.ComponentId;
jsonTypeInfo.PolymorphismOptions.DerivedTypes.Add(new JsonDerivedType(componentType, (int)componentId));
}
}

The resulting serialized blobs may look like this:

"Abilities": [
{
"$type": 63,
"abilitySpecificKeyExample": "ability-specific value",
"Level": 1
},
{
"$type": 109,
"Level": 1
},
{
"$type": 98,
"Level": 2
}
]

The $types are just the AbilityId values.

Serializing read-only properties that are collections

Section titled “Serializing read-only properties that are collections”

I had code like this:

public class LocalSaveData
{
public int FpsLimit { get; set; } = GameConstants.FpsLimitMax;
public Dictionary<VolumeSliderId, float> VolumeSliderValues { get; } =
new()
{
{ VolumeSliderId.Master, 0.6f },
{ VolumeSliderId.Music, 0.8f },
{ VolumeSliderId.Effects, 0.6f },
};
}

The way this is written, if serialized/deserialized, the Dictionary’s values will always be what’s hard-coded there. To fix it, there are two options:

  • Add a set; to the property. This will result in linting rule CA2227 telling you not to do that.
  • Preferred: add [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] just above the Dictionary (reference)

This particular note is just in case this ever comes in handy since this wasn’t the most obvious code to write:

[JsonSourceGenerationOptions(
UseStringEnumConverter = true
Converters = [typeof(ItemConverter), typeof(Vector2Converter), typeof(Vector2IConverter)],
WriteIndented = true
// Note: not all options from JsonSerializerOptions are supported here
)]
[JsonSerializable(typeof(AllSaveData))]
public partial class MyContext : JsonSerializerContext { }
// ...later, serialize using the context.
string jsonString = JsonSerializer.Serialize(allSaveData, new MyContext().AllSaveData);

As mentioned in the note in the comment above, not all of the JsonSerializerOptions are supported in JsonSourceGenerationOptions. You can construct a context using options (just pass it in to new MyContext), although two things:

  • Those options can’t be reused from a past serialization, presumably because they’re modified somehow. To address this, simply make a new JsonSerializerOptions each time.
  • Not all options inside JsonSerializerOptions even seem to be supported, e.g. PolymorphicTypeResolver.

Note that I never tried to make this route work. I’d been exploring it for the sake of serializing enum values as integers, which was solved with [[C sharp and JSON#Serializing enum values as integers]].

Background/context: suppose you have an enumeration like this:

public enum ItemId
{
None = 0,
Sword = 1,
Armor = 2,
// (imagine a bunch more...)
}

If you were to save a raw ItemId or an array/list of ItemIds, they would get saved as integers. However, if you save a Dictionary<ItemId, T>, the keys will be saved as strings:

"ItemsSavedAsArray": [1, 2, 1]
"ItemsSavedAsDictionary": {
"Sword": 2,
"Armor": 1
}

The reason you might not want them to save as strings is in case you ever modify those strings in the code. For example, suppose you mistyped “Sword” as “Swrod”. It would get saved as "Swrod": 2. If you ever fixed that typo in the code, your save files would break unless you wrote special migration code.

Originally, I tried to use UseStringEnumConverter in a JsonSerializerContext, but I ran into two problems:

  • It broke polymorphic serialization since PolymorphicTypeResolver didn’t seem to be applied.
  • It didn’t even work for dictionaries! The ItemIds would still be serialized as strings.

To work around this, you could make a special function to serialize the Dictionary<ItemId, T> as a Dictionary<int, T>, but you would need to do this for every dictionary that you have. Instead, here’s some code that will automatically apply this to all of your dictionaries as long as your JSON serializer has IntEnumKeyDictionaryJsonConverterFactory as a converter (see this issue as a reference; it has code in it, too).

using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using Game.Src.Types.Enums;
public class IntEnumKeyDictionaryJsonConverterFactory : JsonConverterFactory
{
public override bool CanConvert(Type typeToConvert) =>
typeToConvert.IsGenericType
&& typeof(Dictionary<,>).IsAssignableFrom(typeToConvert.GetGenericTypeDefinition())
&& typeToConvert.GetGenericArguments()[0].IsEnum;
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) =>
(JsonConverter?)
Activator.CreateInstance(
typeof(IntEnumKeyDictionaryJsonConverter<,>).MakeGenericType(
typeToConvert.GetGenericArguments()[0],
typeToConvert.GetGenericArguments()[1]
),
options
);
private class IntEnumKeyDictionaryJsonConverter<TEnum, TValue>(JsonSerializerOptions options)
: JsonConverter<Dictionary<TEnum, TValue>>
where TEnum : struct, Enum
{
private readonly JsonConverter<TValue> _valueConverter =
(JsonConverter<TValue>)options.GetConverter(typeof(TValue));
public override Dictionary<TEnum, TValue>? Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options
)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException();
}
Dictionary<TEnum, TValue> result = [];
while (reader.Read())
{
switch (reader.TokenType)
{
case JsonTokenType.EndObject:
return result;
case JsonTokenType.PropertyName:
(TEnum key, TValue value) = ReadNext(ref reader);
result.Add(key, value);
break;
default:
throw new JsonException();
}
}
return result;
}
public override void Write(Utf8JsonWriter writer, Dictionary<TEnum, TValue> dict, JsonSerializerOptions options)
{
writer.WriteStartObject();
foreach ((TEnum key, TValue value) in dict)
{
writer.WritePropertyName(EnumFunctions.ToValue(key).ToString());
_valueConverter.Write(writer, value, options);
}
writer.WriteEndObject();
}
private (TEnum key, TValue value) ReadNext(ref Utf8JsonReader reader)
{
string keyString = reader.GetString() ?? throw new JsonException("Found invalid key in dictionary.");
if (!Enum.TryParse<TEnum>(keyString, out TEnum key))
{
throw new JsonException($"Could not parse key <{keyString}> to enum value of <{typeof(TEnum).Name}>.");
}
// Advance to value
if (!reader.Read())
{
throw new JsonException();
}
TValue value =
_valueConverter.Read(ref reader, typeof(TValue), options)
?? throw new JsonException($"Error parsing value for key <{key}>.");
return (key, value);
}
}
}
ERROR: System.InvalidOperationException: Each parameter in the deserialization constructor on type 'Game.Src.Quests.Quest' must bind to an object property or field on deserialization. Each parameter name must match with a property or field on the object. Fields are only considered when 'JsonSerializerOptions.IncludeFields' is enabled. The match can be case-insensitive.

This error is sort of hard to decipher at first. It means that you’re trying to load data from a JSON file that doesn’t correspond to a constructor or field. For example, suppose I have this in JSON:

"Quest": {
"Objectives": {
"MediumGreenPotion": 6,
"BigLifePotion": 2,
"SmallBluePotion": 11
},
"Progress": {
"MediumGreenPotion": 6,
"BigLifePotion": 2,
"SmallBluePotion": 10
},
"RewardPoints": 100
},

…and this constructor in C#:

public class Quest
{
[JsonConstructor]
public Quest(Dictionary<ItemId, int> objectives, int rewardSize)
{
// ...
}
}

There are two problems above:

  • The JSON has the name RewardPoints but C# uses the name rewardSize. You can fix this simply by renaming rewardSizerewardPoints (since, as the error mentions, the match is case-insensitive).
  • The JSON has a Progress dictionary but the Quest constructor doesn’t take that in. For this fix, it made more sense to make a Progress property (public Dictionary<ItemId, int> Progress { get; init; }).

In conclusion, C# just needs to know how to deserialize what’s found in JSON either via a constructor/property/field.

I had this class and ran into a weird problem:

public partial class IncomeMultiplier(float multiplier)
{
public IncomeMultiplier()
: this(2f) { }
public float Multiplier { get; } = multiplier;
}

…no matter what, deserializing would always have 2f as the number stored. This is because I was missing init in the property:

public float Multiplier { get; } = multiplier;
public float Multiplier { get; init; } = multiplier;

A custom converter is serializing incorrectly

Section titled “A custom converter is serializing incorrectly”
{
"$type": "HeldItemComponent",
"Item":
"id": 1 // ← ❌ This isn't valid JSON
}

This is the fault of the custom converter I wrote:

public class ItemConverter : JsonConverter<Item>
{
public override Item Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return Item.Create((ItemId)reader.GetInt32());
}
public override void Write(Utf8JsonWriter writer, Item value, JsonSerializerOptions options)
{
writer.WriteNumber(System.Text.Encoding.UTF8.GetBytes("id"), (int)value.Id);
}
}

Depending on what you want, there are two different fixes:

  • If the JSON you want is "Item": { "id": 1 }, then surround the call to writer.WriteNumber with writer.WriteStartObject(); and writer.WriteEndObject();
    • This also requires a change to the reader (see below); you generally need to loop on reader.Read() and check for when reader.TokenType is JsonTokenType.StartObject, JsonTokenType.EndObject, or JsonTokenType.PropertyName.
  • If the JSON you want is "Item": 1, then replace writer.WriteNumber with writer.WriteNumberValue.

Similarly, if you wanted to write arrays, you need to explicitly call WriteStartArray and WriteEndArray.

This is kind of ridiculous, but here’s how you would read the { "id": 1 } object. I didn’t need a loop since I only write one value:

public override Item Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException();
}
reader.Read();
// Get the key
if (reader.TokenType != JsonTokenType.PropertyName)
{
throw new JsonException();
}
// Ensure its value is correct
string? propertyName = reader.GetString();
if (propertyName != "id")
{
throw new JsonException();
}
reader.Read();
// Get the value
ItemId itemId = (ItemId)reader.GetInt32();
// Ensure we found the end of the object
reader.Read();
if (reader.TokenType == JsonTokenType.EndObject)
{
return Item.Create(itemId);
}
throw new JsonException();
}
public override void Write(Utf8JsonWriter writer, Item value, JsonSerializerOptions options)
{
writer.WriteStartObject();
writer.WriteNumber(System.Text.Encoding.UTF8.GetBytes("id"), (int)value.Id);
writer.WriteEndObject();
}