C sharp and JSON
General notes
Section titled “General notes”- 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. 😢
Setting default values on load
Section titled “Setting default values on load”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}
Serializing polymorphic types (reference)
Section titled “Serializing polymorphic types (reference)”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 $type
s 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 theDictionary
(reference)
Using a JsonSerializerContext
Section titled “Using a JsonSerializerContext”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]].
Serializing enum
values as integers
Section titled “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 ItemId
s, 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
ItemId
s 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); } }}
Troubleshooting
Section titled “Troubleshooting”Deserialization problems
Section titled “Deserialization problems”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 namerewardSize
. You can fix this simply by renamingrewardSize
→rewardPoints
(since, as the error mentions, the match is case-insensitive). - The JSON has a
Progress
dictionary but theQuest
constructor doesn’t take that in. For this fix, it made more sense to make aProgress
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.
A property isn’t being deserialized
Section titled “A property isn’t being deserialized”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 towriter.WriteNumber
withwriter.WriteStartObject();
andwriter.WriteEndObject();
- This also requires a change to the reader (see below); you generally need to loop on
reader.Read()
and check for whenreader.TokenType
isJsonTokenType.StartObject
,JsonTokenType.EndObject
, orJsonTokenType.PropertyName
.
- This also requires a change to the reader (see below); you generally need to loop on
- If the JSON you want is
"Item": 1
, then replacewriter.WriteNumber
withwriter.WriteNumberValue
.
Similarly, if you wanted to write arrays, you need to explicitly call WriteStartArray
and WriteEndArray
.
Example reader change
Section titled “Example reader change”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();}