🦁

.NET 9 の JSON Schema 生成機能の挙動確認

2025/03/17に公開

.NET 9 で JSON Schema 生成機能が追加されました。この機能を使って、C# クラスから JSON Schema を生成する方法を確認します。

record 型での required の挙動

以前 Azure OpenAIでStructured Outputsを使う! C# 版 という記事で JSON Schema を使って Azure OpenAI の Structured Outputs を使う方法を紹介しました。この時は class を使って JSON Schema を生成しましたが、今回は record を使って JSON Schema を生成すると required がどうなるか確認します。

まずは、何も考えずに record を使って JSON Schema を生成してみます。
今回は Person という record を使って JSON Schema を生成します。NameAge というプロパティを持つ Person です。

using System.Text.Json;
using System.Text.Json.Schema;
using System.Text.Json.Serialization.Metadata;

// Person record から JSON Schema を生成する
var jsonSchema = JsonSchemaExporter.GetJsonSchemaAsNode(
    options: new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
    },
    typeof(Person),
    exporterOptions: new()
    {
        TreatNullObliviousAsNonNullable = true,
    });

// JSON Schema を JSON 文字列に変換して表示する
Console.WriteLine(jsonSchema.ToJsonString(options: new() { WriteIndented = true }));

// Person record
record Person(string Name, int Age);

実行すると以下の JSON Schema が生成されます。何も考えずにプライマリーコンストラクターの引数に指定してあるプロパティが required になっていることがわかります。

{
  "type": "object",
  "properties": {
    "name": {
      "type": "string"
    },
    "age": {
      "type": "integer"
    }
  },
  "required": [
    "name",
    "age"
  ]
}

required を外したい場合は通常のプロパティとして定義する必要があります。例えば Age プロパティを required から外す場合は以下のようにします。

// Person record
record Person(string Name)
{
    public int Age { get; set; }
}

このようにすると Age プロパティが required から外れます。実行結果を以下に示します。

{
  "type": "object",
  "properties": {
    "name": {
      "type": "string"
    },
    "age": {
      "type": "integer"
    }
  },
  "required": [
    "name"
  ]
}

Nullable の挙動

プロパティの型を int? のような null 許容値型や string? のような null 許容参照型にすると、JSON Schema に null 許容値型として出力されます。以下の例では NameAge プロパティの型に ? を追加しています。

record Person(
    string? Name)
{
    public int? Age { get; set; }
}

実行結果を以下に示します。

{
  "type": "object",
  "properties": {
    "name": {
      "type": [
        "string",
        "null"
      ]
    },
    "age": {
      "type": [
        "integer",
        "null"
      ]
    }
  },
  "required": [
    "name"
  ]
}

Description 対応

デフォルトでは、以下のように Description 属性を追加しても何も起きません。

record Person(
    [property: Description("氏名")]
    string Name)
{
    [Description("年齢")]
    public int? Age { get; set; }
}

これに対応するためには exporterOptions で設定している JsonSchemaExporterOptionsTransformSchemaNode プロパティを使って動作をカスタマイズする必要があります。よくあるパターンだと思うので公式ドキュメントにもサンプルがあります。

では、コードを書き換えて TransformSchemaNode を使って Description 属性を使うようにしてみます。

using System.ComponentModel;
using System.Diagnostics;
using System.Reflection;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Schema;
using System.Text.Json.Serialization.Metadata;
using System.Text.Unicode;

// Person record から JSON Schema を生成する
var jsonSchema = JsonSchemaExporter.GetJsonSchemaAsNode(
    options: new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
    },
    typeof(Person),
    exporterOptions: new()
    {
        TreatNullObliviousAsNonNullable = true,
        TransformSchemaNode = (context, schema) =>
        {
            // Determine if a type or property and extract the relevant attribute provider.
            ICustomAttributeProvider? attributeProvider = context.PropertyInfo is not null
                ? context.PropertyInfo.AttributeProvider
                : context.TypeInfo.Type;

            // Look up any description attributes.
            DescriptionAttribute? descriptionAttr = attributeProvider?
                .GetCustomAttributes(inherit: true)
                .Select(attr => attr as DescriptionAttribute)
                .FirstOrDefault(attr => attr is not null);

            // Apply description attribute to the generated schema.
            if (descriptionAttr != null)
            {
                if (schema is not JsonObject jObj)
                {
                    // Handle the case where the schema is a Boolean.
                    JsonValueKind valueKind = schema.GetValueKind();
                    Debug.Assert(valueKind is JsonValueKind.True or JsonValueKind.False);
                    schema = jObj = new JsonObject();
                    if (valueKind is JsonValueKind.False)
                    {
                        jObj.Add("not", true);
                    }
                }

                jObj.Insert(0, "description", descriptionAttr.Description);
            }

            return schema;
        }
    });

// JSON Schema を JSON 文字列に変換して表示する
Console.WriteLine(jsonSchema.ToJsonString(options: new() 
{ 
    WriteIndented = true,
    Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
}));

// Person record
record Person(
    [property: Description("氏名")]
    string Name)
{
    [Description("年齢")]
    public int? Age { get; set; }
}

地味に最後の JSON Schema を生成するところに日本語がエスケープされないようにおまじないを追加していますが気にしないでください。実行すると以下の JSON Schema が生成されます。Description 属性が反映されていることがわかります。

{
  "type": "object",
  "properties": {
    "name": {
      "description": "氏名",
      "type": "string"
    },
    "age": {
      "description": "年齢",
      "type": [
        "integer",
        "null"
      ]
    }
  },
  "required": [
    "name"
  ]
}

enum を文字列化したい

enum を普通に使うと数字にされてしまいます。例えば以下のような Rank という enum を使ってみます。

record Person(
    [property: Description("氏名")]
    string Name)
{
    [Description("年齢")]
    public int? Age { get; set; }

    [Description("ランク")]
    public Rank Rank { get; set; }
}

enum Rank
{
    [Description("未設定")]
    None,
    [Description("子供")]
    Child,
    [Description("大人")]
    Adult,
    [Description("シニア")]
    Senior,
}

実行すると integer 扱いされていることがわかります。

{
  "type": "object",
  "properties": {
    "name": {
      "description": "氏名",
      "type": "string"
    },
    "age": {
      "description": "年齢",
      "type": [
        "integer",
        "null"
      ]
    },
    "rank": {
      "description": "ランク",
      "type": "integer"
    }
  },
  "required": [
    "name"
  ]
}

以下のように enumJsonStringEnumConverter を使って文字列化することで string として出力されるようになります。

[JsonConverter(typeof(JsonStringEnumConverter))]
enum Rank
{
    [Description("未設定")]
    None,
    [Description("子供")]
    Child,
    [Description("大人")]
    Adult,
    [Description("シニア")]
    Senior,
}

実行結果は以下のようになります。

{
  "type": "object",
  "properties": {
    "name": {
      "description": "氏名",
      "type": "string"
    },
    "age": {
      "description": "年齢",
      "type": [
        "integer",
        "null"
      ]
    },
    "rank": {
      "description": "ランク",
      "enum": [
        "None",
        "Child",
        "Adult",
        "Senior"
      ]
    }
  },
  "required": [
    "name"
  ]
}

因みに enum につけた Description 属性を JSON Schema に反映する方法はわかりませんでした…。

JsonSerializerOptions から JSON Schema を生成する

今まで JsonSchemaExporter.GetJsonSchemaAsNode を使って JSON Schema を生成していましたが、JsonSerializerOptions からも JSON Schema を生成することができます。以下のように JsonSerializerOptions から JSON Schema を生成してみます。

// Person record から JSON Schema を生成する
var options = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
};

// JsonSerializerOptions に GetJsonSchemaAsNode 拡張メソッドがある
var jsonSchema = options.GetJsonSchemaAsNode(
    typeof(Person),
    exporterOptions: new()
    {
        // 省略
    });

複数の型を受け取るプロパティの場合

enum では諦めましたが以下のようにクラスの継承を使って複数の型を受け取るプロパティを定義すると enum で諦めた Description 付きの enum のようなことが出来ます。PersonRank プロパティを IRank インターフェースにして、それを実装する None, Child, Adult, Senior クラスを作成します。

// Person record
record Person(
    [property: Description("氏名")]
    string Name)
{
    [Description("年齢")]
    public int? Age { get; set; }

    [Description("ランク")]
    public required IRank Rank { get; set; } = new None();
}

[JsonDerivedType(typeof(None), typeDiscriminator: "none")]
[JsonDerivedType(typeof(Child), typeDiscriminator: "child")]
[JsonDerivedType(typeof(Adult), typeDiscriminator: "adult")]
[JsonDerivedType(typeof(Senior), typeDiscriminator: "senior")]
interface IRank
{
}

[Description("未設定")]
class None : IRank;
[Description("子供")]
class Child : IRank;
[Description("大人")]
class Adult : IRank;
[Description("シニア")]
class Senior : IRank;

これを実行すると以下のようになります。ばっちり。

{
  "type": "object",
  "properties": {
    "name": {
      "description": "氏名",
      "type": "string"
    },
    "age": {
      "description": "年齢",
      "type": [
        "integer",
        "null"
      ]
    },
    "rank": {
      "description": "ランク",
      "type": "object",
      "required": [
        "$type"
      ],
      "anyOf": [
        {
          "description": "未設定",
          "properties": {
            "$type": {
              "const": "none"
            }
          }
        },
        {
          "description": "子供",
          "properties": {
            "$type": {
              "const": "child"
            }
          }
        },
        {
          "description": "大人",
          "properties": {
            "$type": {
              "const": "adult"
            }
          }
        },
        {
          "description": "シニア",
          "properties": {
            "$type": {
              "const": "senior"
            }
          }
        }
      ]
    }
  },
  "required": [
    "name",
    "rank"
  ]
}

因みに以下のような IRank インターフェースを実装したクラスを定義するとその他みたいなものもいい感じに表現できそうです。

[Description("その他")]
class Other : IRank
{
    [Description("特記事項")]
    public string? Notes { get; set; }
}

生成される JSON Schema は以下のようになります。

{
  "type": "object",
  "properties": {
    "name": {
      "description": "氏名",
      "type": "string"
    },
    "age": {
      "description": "年齢",
      "type": [
        "integer",
        "null"
      ]
    },
    "rank": {
      "description": "ランク",
      "type": "object",
      "required": [
        "$type"
      ],
      "anyOf": [
        {
          "description": "未設定",
          "properties": {
            "$type": {
              "const": "none"
            }
          }
        },
        {
          "description": "子供",
          "properties": {
            "$type": {
              "const": "child"
            }
          }
        },
        {
          "description": "大人",
          "properties": {
            "$type": {
              "const": "adult"
            }
          }
        },
        {
          "description": "シニア",
          "properties": {
            "$type": {
              "const": "senior"
            }
          }
        },
        {
          "description": "その他",
          "properties": {
            "$type": {
              "const": "other"
            },
            "notes": {
              "description": "特記事項",
              "type": [
                "string",
                "null"
              ]
            }
          }
        }
      ]
    }
  },
  "required": [
    "name",
    "rank"
  ]
}

まとめ

生成 AI を使うアプリケーションでは最終的にユーザーに表示する文字列を生成する箇所では、普通に生成 AI の回答を表示するので良いですが、プログラムで処理するための値を生成 AI に作ってもらうためには Structured Output を使うのがやりやすいです。その際に出力するデータ構造を指定する方法として JSON Schema が使われています。
そのため、JSON Schema をプログラムで生成する需要が以前より増えて来ています。ということで .NET も .NET 9 から JSON Schema 生成機能が追加されています。今回はその機能を使って JSON Schema を生成する方法を確認しました。

Microsoft (有志)

Discussion