Skip to content

[C#] Generated JsonPatch propagation throws for nullable nested dynamic model properties #11834

Description

@jsquire

Summary

The C# emitter generates PropagateGet code that dereferences a nested dynamic-model property before checking whether that property is present.

This causes JsonPatch.GetJson, TryGetJson, and TryGetValue to throw NullReferenceException when the service supplies a nested model property as JSON null, or omits that property.

This is related to, but distinct from, #11616 and its fix in #11632. Those changes added collection bounds checks. This report concerns direct nullable nested-model propagation.

Root cause

MrwSerializationTypeDefinition.Dynamic.cs emits propagation through nested model properties in this shape:

return Error.Patch.TryGetEncodedValue(
    [.. "$"u8, .. currentSlice],
    out value);

For a nullable nested model property, deserialization can correctly produce Error == null. The emitted propagation code then dereferences Error.Patch and throws.

The same generated shape appears for every nullable nested dynamic-model property.

Downstream example

openai/openai-dotnet generates this pattern for nullable nested properties on ResponseResult, including:

  • error
  • incomplete_details
  • reasoning
  • text
  • usage
  • conversation

The underlying API contract permits error to be explicitly null:

error: ResponseError | null;

After deserializing a valid response with "error": null, the typed property is correctly null, but reading the corresponding patch path fails:

var response = ModelReaderWriter.Read<ResponseResult>(
    BinaryData.FromString("""
    {
      "id": "resp_1",
      "object": "response",
      "created_at": 1730000000,
      "status": "completed",
      "error": null,
      "model": "gpt-4o-mini",
      "output": []
    }
    """))!;

Console.WriteLine(response.Error is null); // True

#pragma warning disable OPENAI001
Console.WriteLine(response.Patch.GetJson("$.error"u8)); // NullReferenceException
#pragma warning restore OPENAI001

TryGetJson and TryGetValue throw identically, so consumers cannot use the expected non-throwing probe pattern.

Expected behavior

Propagation should distinguish these cases:

Wire representation Expected patch behavior
"error": null GetJson("$.error") returns the JSON null value
error omitted TryGet...("$.error", ...) returns false
Nested model present Propagation continues into the nested model as it does today

A null guard that always returns false would prevent the exception, but it would lose the explicit JSON null representation. The generated dynamic-model implementation needs to preserve that distinction.

Suggested investigation

The emitter should retain enough patch state to resolve an explicitly null nested-model property without dereferencing its typed value. This likely applies to generated PropagateGet, and PropagateSet should also be reviewed for nullable nested-model behavior.

Suggested tests

Add generator-level and end-to-end coverage for a dynamic model with a nullable nested model property.

The tests should deserialize both of these inputs:

{ "child": null }
{}

They should verify:

Assert.That(model.Patch.GetJson("$.child"u8).ToString(), Is.EqualTo("null"));

Assert.That(
    model.Patch.TryGetValue("$.child"u8, out string? value),
    Is.False);

The first assertion validates preservation of an explicit JSON null. The second validates that an omitted property is non-throwing and unresolved.

Related issues


🤖 jsquire-copilot

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

    emitter:client:csharpIssue for the C# client emitter: @typespec/http-client-csharp

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions