ppy/osu · error · JsonException

Expected $type token.

Error message

Expected $type token.

What it means

Thrown by TypedListConverter<T>.ReadJson while iterating $items in a typed-list JSON payload: each item object must carry a $type token indexing into $lookup_table. If an item lacks $type, the converter cannot resolve which concrete type to instantiate, so it throws a JsonException before the lookup index read.

Source

Thrown at osu.Game/IO/Serialization/Converters/TypedListConverter.cs:63

            var obj = JObject.Load(reader);

            if (obj["$lookup_table"] == null)
                return list;

            var lookupTable = serializer.Deserialize<List<string>>(obj["$lookup_table"].CreateReader());
            if (lookupTable == null)
                return list;

            if (obj["$items"] == null)
                return list;

            foreach (var tok in obj["$items"])
            {
                var itemReader = tok.CreateReader();

                if (tok["$type"] == null)
                    throw new JsonException("Expected $type token.");

                // Prevent instantiation of types that do not inherit the type targetted by this converter
                Type type = Type.GetType(lookupTable[(int)tok["$type"]]).AsNonNull();
                if (!type.IsAssignableTo(typeof(T)))
                    continue;

                var instance = (T)Activator.CreateInstance(type)!;
                serializer.Populate(itemReader, instance);

                list.Add(instance);
            }

            return list;
        }

        public override void WriteJson(JsonWriter writer, IReadOnlyList<T> value, JsonSerializer serializer)
        {
            var lookupTable = new List<string>();

View on GitHub (pinned to d9c73e12ad)

Solutions

  1. Ensure the JSON was produced by the matching WriteJson (it always emits $type first via JProperty); re-serialise the source if needed.
  2. Validate the payload schema before deserialisation: every entry in $items must contain $type as an int within $lookup_table bounds.
  3. If tolerating old/partial data is required, subclass/override ReadJson to skip items missing $type with a warning instead of throwing.

Example fix

// before
if (tok["$type"] == null)
    throw new JsonException("Expected $type token.");

// after (tolerant: skip malformed entries)
if (tok["$type"] == null)
{
    Logger.Log($"Skipped typed-list item missing $type token.", LoggingTarget.Database, LogLevel.Important);
    continue;
}
Defensive patterns

Strategy: validation

Validate before calling

// Validate each $items entry has an int $type within lookup bounds before deserialising.
foreach (var tok in payload["$items"])
    if (tok["$type"] == null || (int)tok["$type"] < 0 || (int)tok["$type"] >= lookupTable.Count)
        throw new InvalidDataException("Malformed typed-list item: missing/invalid $type");

Type guard

static bool HasValidTypeToken(JToken item, List<string> lookup)
    => item["$type"] != null && (int)item["$type"] >= 0 && (int)item["$type"] < lookup.Count;

Try / catch

try { var list = serializer.Deserialize<IReadOnlyList<T>>(reader); }
catch (JsonException ex) when (ex.Message.Contains("$type token"))
{ /* reject/re-serialise the payload from a trusted source */ }

Prevention

When it happens

Trigger: Deserialising a beatmap JSON (or any TypedListConverter-backed list) whose $items entries are missing the $type property — caused by hand-edited JSON, a serialiser that omitted $type, or a version/format mismatch between writer and reader.

Common situations: Loading a .osu beatmap JSON edited externally; a serialisation round-trip through a custom JsonSerialiserSettings that strips null/zero tokens; cross-version beatmap files whose schema predates $type.

Related errors


AI-assisted analysis of ppy/osu@d9c73e12ad (2026-08-13). Data as JSON: /api/errors/4ec557352d7f93ab. Report an issue: GitHub.