Skip to content

Investigate STJM compatibility and improvement opportunities in .NET 11 RC1 #182

Description

@egil

STJM assessment of .NET 11 RC1

Research date: 2026-09-13.

STJM's existing behavior passes on RC1 without production changes. New .NET 11 scenarios expose three gaps: migration from new numeric primitives, applying [JsonMigratable] directly to a C# union, and using migratable cases with the built-in union structural classifier. Explicit union classifiers offer a working integration opportunity. They do not replace STJM's migration engine or remove its private STJ dependency.

Provenance and scope

  • Repository: https://github.com/egil/framework.git.
  • Fresh git fetch origin main, then git worktree add -b egil/stjm-dotnet11-research /tmp/framework-stjm-dotnet11 origin/main.
  • Reviewed HEAD and fetched origin/main: 5a7cb4aae019730ffb2248d7e6ca17dc8a2f02c6. The local main branch was not used as the base.
  • Runtime tested: 11.0.0-rc.1.26425.128; SDK: 11.0.100-rc.1.26425.128. RC1 was released September 8, 2026. Official download.
  • Reviewed Preview 1–7 and RC1 library release notes, linked runtime issues/PRs, tagged reference APIs and selected implementations. Runtime tag and actual installed behavior take precedence over proposal examples and overview pages.
  • Repository change: this research document only. Experiments use a separate temporary console project referencing the unchanged STJM build. No implementation, CI edits, commits, pushes, or GitHub issues were made.

What the current implementation depends on

Paths below are relative to Egil.SystemTextJson.Migration/ at the reviewed commit.

Code Relevant behavior
src/Egil.SystemTextJson.Migration/Migrations/StjInternals.cs Uses UnsafeAccessor to call private JsonConverter.ReadAsObject; documented against .NET 10.
Migrations/JsonMigratableConverter.cs in the same source directory Calls target converter Read directly; invokes source converters through the private accessor; inspects the first property for a migration discriminator.
Migrations/JsonMigratableConverterFactory.cs Clones options, excludes the target from recursive converter registration, modifies resolved metadata to inject a discriminator property, and freezes options. Injection assumes a contract accepting object properties.
Migrations/JsonMigratableConverter.NonObjectMatching.cs Selects candidates using contract kinds and hardcoded CLR primitive lists. Number matching relies on Type.GetTypeCode.
docs/recipes/polymorphism.md Already prohibits standard STJ polymorphism on the migration hierarchy and anticipates future classifier/metadata support.
docs/recipes/aot-source-gen.md Claims no reflection for migration, despite factory discovery, MakeGenericType, Activator.CreateInstance, and delegate construction in current implementation.

The package, tests and samples target net10.0; the CI workflow installs 10.x only. There is no explicit STJ package dependency. A .NET 11 consumer can therefore exercise a newer framework STJ against the existing net10 library asset. A new package target is not automatically necessary to establish runtime compatibility.

Compatibility evidence

The unchanged Release solution built with the installed RC1 SDK against net10.0. Its normal test execution used .NET 10. The same compiled test executables were then launched with an explicit RC1 framework version; their banners confirmed .NET 11.0.0-rc.1.26425.128.

Run Result
Existing behavior suite on .NET 10 200 passed, 0 failed/skipped
Existing sample suite on .NET 10 44 passed, 0 failed/skipped
Same net10 behavior executable on RC1 200 passed, 0 failed/skipped
Same net10 sample executable on RC1 44 passed, 0 failed/skipped

Commands from the research worktree:

dotnet test Egil.SystemTextJson.Migration/Egil.SystemTextJson.Migration.slnx -c Release --nologo
dotnet exec --fx-version 11.0.0-rc.1.26425.128 Egil.SystemTextJson.Migration/test/Egil.SystemTextJson.Migration.Tests/bin/Release/net10.0/Egil.SystemTextJson.Migration.Tests.dll
dotnet exec --fx-version 11.0.0-rc.1.26425.128 Egil.SystemTextJson.Migration/samples/Egil.SystemTextJson.Migration.Samples/bin/Release/net10.0/Egil.SystemTextJson.Migration.Samples.dll

This establishes compatibility for the exercised existing behavior, including the private call path. It is not a NativeAOT, trimming, net11 source-generation, performance, or final .NET 11 certification. The package was not packed/published and no benchmark was run.

Required work for the new scenarios

1. New numeric migration sources are not recognized

RC1 adds STJ converters for System.Numerics.BFloat16, Decimal32, Decimal64, and Decimal128. Implementation PR #131523, RC1 release notes.

Reproduced with Decimal64: native Deserialize<Decimal64>("41") succeeds; an object with a Decimal64 property also succeeds through STJM. But registering IMigrateFrom<Decimal64, NumericPayload> and deserializing raw 41 into that target fails with JsonException. STJM's IsTokenCompatibleWithSourceType never selects this source, so it falls through to object deserialization.

Recommendation: extend the numeric source classification deliberately and audit all four new primitives, including collection element disambiguation. Keep ordinary object-property handling delegated to STJ. Protect ambiguity behavior: two numeric candidates must not silently select whichever was registered first. Verify existing non-TypeCode numerics as part of the same narrowly scoped classification review; do not assume the gap began with .NET 11.

The absence of names in installed XML documentation initially suggested a mismatch; compiled code and runtime enumeration confirmed availability. Documentation text searches are not sufficient API evidence.

2. A union cannot currently be the migratable target

Reproduced: [JsonMigratable] public union MigrationOnUnion(int, string); fails for input 42 with InvalidOperationException: Invalid JsonTypeInfo operation for JsonTypeInfoKind 'Union'.

The factory's unconditional discriminator-property injection encounters the new Union contract kind. Native unions serialize their active case without an object envelope. Simply skipping injection would change version tagging and could make migration routing unreliable. Union implementation #128162.

Recommendation: first define and document the boundary, with a clear early diagnostic for direct union targets. Keep migrations on object case types or an explicit versioned object wrapper. Full direct-union migration support needs a wire-format decision and coverage for round trips, ambiguous sources and null cases; it is a feature, not a one-line compatibility patch.

3. Structural union classification loses STJM's object shape

Reproduced: a structural union of two ordinary object types succeeds. Replacing one with an equivalent migratable object produces NotSupportedException: the two cases overlap on the Object JSON shape, with one lacking usable object-property metadata.

STJM supplies a custom converter. The built-in structural classifier cannot inspect it as a normal STJ object contract. This is a specific combination limitation: a default union of a migratable object and string succeeded. Structural classifier PR #132427, tagged implementation.

Recommendation: document and test an explicit migration-aware classifier for union cases. A prototype factory selecting the current case for a known legacy discriminator successfully performed Old(Value=41) -> Current(Value=42) and serialized {"$type":"current","value":42}. A reusable adapter should validate registered cases/discriminators, reject unknown versions, handle configured discriminator names, and preserve ambiguity rules. The small probe below demonstrates the seam; its permissive dispatch is not a production-ready classifier.

What classifier support does and does not enable

JsonTypeClassifier, its factories, JsonTypeInfo.TypeClassifier, JsonPolymorphicAttribute.TypeClassifier, and JsonSerializerOptions.TypeClassifiers ship in RC1. The classifier reads a defensive reader copy and returns a CLR type. It can route a union case to STJM; it cannot mutate the original payload or implement the source-to-target transformation by itself. Tagged classifier contract.

The metadata blocker is separate. JsonConverter.CanHaveMetadata remains internal and issue #118900 remains Open/Future. The inspected RC1 source still has the same private ReadAsObject(ref Utf8JsonReader, Type, JsonSerializerOptions) signature. No public replacement was found. Standard polymorphic discriminator handling still checks converter metadata support. Tagged JsonConverter, tagged polymorphism resolver.

Update polymorphism.md to distinguish shipped classification from unavailable converter metadata support. #127299 and #125449 are closed; their historical proposal bodies contain names that differ from shipped APIs. The existing prohibition should not be removed merely because those issues closed.

For long-term resilience, consider putting typed source-reader delegates into migrator registrations to avoid the private non-generic call. That is an STJM design experiment, not a new RC1 API capability. It must preserve nested migration, custom converter semantics and performance before replacing the current path.

Other changes and opportunities

The published-package dotnet-inspect comparison reported 32 additive changes across 19 types, with no removals detected. A separate tagged-reference comparison also covered attribute target changes that a simple member list misses. This is evidence of public API evolution, not a proof of behavioral compatibility. 10.0 reference, RC1 reference.

Change STJM assessment and next step
Generic GetTypeInfo<T> / TryGetTypeInfo<T> Useful typed metadata convenience, but the factory discovers source types at runtime. No urgent rewrite. #123940.
PascalCase policy and per-member naming policy Basic discriminator round trip passed with PascalCase options and a SnakeCaseLower member override. Extend compatibility coverage to explicit-name precedence, nested types and generated metadata. #124644, #124645.
Type-level ignore defaults Basic WhenWritingNull probe preserved the injected discriminator and omitted a null property. Check other ignore conditions and generated contracts before broad support claims. #124646.
Closed hierarchy inference Reduces upstream subtype registration work; does not bypass the custom-converter metadata restriction. Retain wrapper boundaries and explicit migration version identifiers. #130808, #131623.
F# discriminated unions Reflection converter support; the implementation PR explicitly excludes source generation/NativeAOT support. Fieldless string cases and fieldful object cases warrant separate investigation if STJM adopts F# scenarios. Not tested here. #125610.
SerializeAsyncEnumerable, including PipeWriter and top-level values Useful NDJSON read/migrate/write-back recipe. Caller can pass migration-enabled options. Preserve cancellation, failures and record boundaries in an end-to-end sample. #127567.
Utf8JsonWriter.Reset(destination, options) Little direct benefit: STJM receives a caller-owned writer and does not manage a writer pool. Benchmark only if a future batch adapter owns writers. #126578.
Private-member source-generation access / generic accessor fixes More consumer model shapes become possible. Add an actual NativeAOT consumer smoke application before claiming AOT compatibility. Correct the existing no-reflection documentation independently. #124650, #126507.
Scoped reader original-position fix Relevant because STJM bypasses this path. Evaluate error path/reader position parity and benchmarks if changing the optimization. #127679.
IReadOnlySet<T> support Primarily a compatibility/sample addition; existing Enumerable handling should be evaluated using real contracts. Not independently exercised here. #120306.
Binary schema contentEncoding Helps schema consumers, but adds no schema-version migration engine. #130881.
DOM and reader surface No public additions found for JsonNode/JsonObject/JsonArray/JsonElement/JsonDocument/Utf8JsonReader in the tagged comparison. No new DOM transformation primitive motivating an STJM rewrite.

Suggested delivery order

  1. Compatibility CI and accurate documentation. Keep net10 consumer compatibility; add an explicit .NET 11 runtime lane for existing binaries and a net11 consumer project for new APIs. Preserve the private-access path as a visible compatibility checkpoint. Clarify union, polymorphism and AOT scope.
  2. Numeric source matching. Add meaningful regression cases for new primitives, nested collections and ambiguous numeric candidates; extend classification without changing unrelated dispatch rules.
  3. Union boundary and recipe. Add a deliberate direct-union diagnostic, plus positive/negative coverage for default, structural and explicit classification. Publish the proven classifier composition as a recipe before designing a generalized adapter API.
  4. AOT validation and registration design. Publish and execute a representative native consumer with reflection-based serialization disabled, covering static/external migrations and generated metadata. Investigate explicit typed registration only if the evidence requires it.
  5. Optional conveniences. NDJSON batch example, wider naming/ignore combinations and any performance experiments driven by measured workloads.

Keep net10.0 support. Add a net11.0 package asset or optional integration package only when implementation actually consumes the new APIs. The existing tests passing on RC1 do not require a consumer-facing major-version bump on their own.

Reproducible exploratory probes

The following console program was compiled for net11.0 with LangVersion=preview, nullable and implicit usings enabled, referencing the unchanged Release net10 STJM DLL by HintPath. It uses observations rather than assertions so expected exceptions can be recorded together; it is research evidence, not proposed repository test code. Build outside this repository to avoid inheriting package/analyzer configuration.

dotnet run --project /tmp/stjm-rc1-probe/Probe.csproj -c Release
using System.Numerics;
using System.Text.Json;
using System.Text.Json.Serialization;
using Egil.SystemTextJson.Migration;

Console.WriteLine(System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription);
Console.WriteLine("New numeric types in corelib: " + string.Join(",", typeof(decimal).Assembly.GetTypes().Where(t => t.Name is "Decimal32" or "Decimal64" or "Decimal128" or "BFloat16").Select(t => t.FullName)));
Run("native Decimal64", () => JsonSerializer.Deserialize<Decimal64>("41"));
Run("Decimal64 property", () => JsonSerializer.Deserialize<NumericPayload>("{\"$type\":\"numeric\",\"Value\":41}", Options()));
Run("Decimal64 source migration", () => JsonSerializer.Deserialize<NumericPayload>("41", Options()));
Run("union plain object plus string", () => JsonSerializer.Deserialize<PlainUnion>("{\"Value\":42}"));
Run("union migrating object plus string", () => JsonSerializer.Deserialize<MigratingUnion>("{\"$type\":\"current\",\"Value\":42}", Options()));
Run("structural plain", () => JsonSerializer.Deserialize<PlainStructural>("{\"Value\":42}"));
Run("structural migrating", () => JsonSerializer.Deserialize<MigratingStructural>("{\"$type\":\"current\",\"value\":42}", Options()));
Run("explicit classifier migrating", () => JsonSerializer.Deserialize<ExplicitMigrating>("{\"$type\":\"current\",\"value\":42}", Options()));
Run("explicit classifier old payload migration", () => {
 var result = JsonSerializer.Deserialize<ExplicitMigrating>("{\"$type\":\"old\",\"Value\":41}", Options());
 return JsonSerializer.Serialize(result, Options());
});
Run("migratable union target", () => JsonSerializer.Deserialize<MigrationOnUnion>("42", Options()));
Run("naming and ignore", () => {
 var options = Options();
 options.PropertyNamingPolicy = JsonNamingPolicy.PascalCase;
 var json = JsonSerializer.Serialize(new Current(42), options);
 var copy = JsonSerializer.Deserialize<Current>(json, options);
 return $"{json} -> {copy}";
});

static JsonSerializerOptions Options() { var options = new JsonSerializerOptions(); options.AddJsonMigrationSupport(); return options; }
static void Run(string label, Func<object?> action) { try { Console.WriteLine($"{label}: OK {action()}"); } catch (Exception ex) { Console.WriteLine($"{label}: {ex.GetType().Name}: {ex.Message}"); } }
public record Plain(int Value);
[JsonMigratable(TypeDiscriminator = "current")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public record Current([property: JsonNamingPolicy(JsonKnownNamingPolicy.SnakeCaseLower)] int Value) : IMigrateFrom<Old, Current>
{
 public string? Empty {get;set;}
 public static bool TryMigrateFrom(Old source, out Current result) { result = new Current(source.Value + 1); return true; }
}
[JsonMigratable(TypeDiscriminator = "old")]
public record Old(int Value);
public union PlainUnion(Plain, string);
public union MigratingUnion(Current, string);
[JsonMigratable(TypeDiscriminator = "union")]
public union MigrationOnUnion(int, string);

public record Other(string Name);
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union PlainStructural(Plain, Other);
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union MigratingStructural(Current, Other);

[JsonUnion(TypeClassifier = typeof(MigrationClassifier))]
public union ExplicitMigrating(Current, Other);
public sealed class MigrationClassifier : JsonTypeClassifierFactory<ExplicitMigrating>
{
    public override JsonTypeClassifier CreateJsonClassifier(JsonTypeClassifierContext context, JsonSerializerOptions options)
        => static (ref Utf8JsonReader reader) =>
        {
            if (reader.TokenType == JsonTokenType.StartObject && reader.Read() && reader.ValueTextEquals("$type"u8))
                return typeof(Current);
            return typeof(Other);
        };
}

[JsonMigratable(TypeDiscriminator = "numeric")]
public record NumericPayload(Decimal64 Value) : IMigrateFrom<Decimal64, NumericPayload>
{
 public static bool TryMigrateFrom(Decimal64 source, out NumericPayload result) { result = new NumericPayload(source); return true; }
}

Observed output (excluding MSBuild server fallback notice):

.NET 11.0.0-rc.1.26425.128
New numeric types in corelib: System.Numerics.Decimal128,System.Numerics.Decimal32,System.Numerics.Decimal64,System.Numerics.BFloat16
native Decimal64: OK 41
Decimal64 property: OK NumericPayload { Value = 41 }
Decimal64 source migration: JsonException: The JSON value could not be converted to NumericPayload. Path: $ | LineNumber: 0 | BytePositionInLine: 2.
union plain object plus string: OK PlainUnion
union migrating object plus string: OK MigratingUnion
structural plain: OK PlainStructural
structural migrating: NotSupportedException: The JsonUnionTypeStructuralClassifier cannot classify union type 'MigratingStructural' because the case types 'Other' and 'Current' can both deserialize JSON values of type 'Object'. At most one case without object property metadata per JSON value type is supported.
explicit classifier migrating: OK ExplicitMigrating
explicit classifier old payload migration: OK {"$type":"current","value":42}
migratable union target: InvalidOperationException: Invalid JsonTypeInfo operation for JsonTypeInfoKind 'Union'.
naming and ignore: OK {"$type":"current","value":42} -> Current { Value = 42, Empty =  }

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions