鷲ノ巣

C# とか PowerShell とか。当ブログの記事は(特に公開直後は)頻繁に改定される場合があることをご了承ください。

JsonSerializer のシリアライズ処理を一元的にカスタマイズする

本記事は C# Advent Calendar 2025 シリーズ2の14日目の記事です。
qiita.com

目的

例えば、JsonSerializerシリアライズする際に、特定の型のメンバーだけをシリアライズから除外したい場合はどうすればいいでしょうか。

たとえばこんなクラスがあって、Dataシリアライズするけれども、その中の NonSerializable は除外したいというような場合です。

public sealed record NonSerializable(int Value);
public sealed record Data(int X, NonSerializable NS);

端的に結論から言いますと、こんな感じです。
ただ、先に注意しておきますと、これは .NET 10 以降でしか動かない可能性があります。

var options = new JsonSerializerOptions();
var resolver = options.TypeInfoResolver;

resolver = resolver.WithAddedModifier(static typeInfo =>
{
    var props = typeInfo.Properties;

    foreach (var prop in props.ToArray())
    {
        if (prop.PropertyType == typeof(NonSerializable))
        {
            props.Remove(prop);
        }
    }
});

options.TypeInfoResolver = resolver;

var data = new Data(1, new NonSerializable(2));
var json = JsonSerializer.Serialize(data, options);

さて、皆さん、これでいいじゃないかと思われるかもしれません。

record Data(
    int Value,
    [property: JsonIgnore] NonSerializable NS);

もちろんこれでもできます。ただ、今回は何らかの事情により、これをやりたくない、あるいはできない場合にどうするか、という話です。
たとえば、NonSerializable 型があちこちに出てくるので [property: JsonIgnore] を漏れなく付けるのが大変だとか、Data 型がシリアライズのために用意されたものではないのでシリアライズ用の属性を付けるのは違和感があるとか、あるいはそもそも Data 型のソースコードに手を加えられないとか。

概念的な話

で、一体先のコードは何をしているのかという話をしていきましょう。

リアライザは対象の型(上記で言うと Data)をシリアライズするにあたって、型のメタデータを収集する必要があります。
型にはどんなメンバーがあるのかとか、それぞれのメンバーにどういう指示がされているのかとか。
使用する JsonConverter を特定するとか、JsonIgnoreAttribute の有無を調べるとかです。

そのための方法として真っ先に考えられるのはリフレクションでしょう。
ただ、最近の .NET では、AOT コンパイルを考慮して、リフレクションは避けられる傾向にあります。
代わりに使われるのがソース ジェネレーターです。
コンパイル後のバイナリ内のメタデータを調べるリフレクションと違い、コンパイル前のソースコードを元に型メタデータを特定する、言わばコンパイル タイム リフレクションと言えるのがソース ジェネレーターです。

JsonSerializer が型メタデータを表現するために用意されているのが JsonTypeInfo クラスです。
リフレクションの場合でもソース ジェネレーターの場合でも、一旦この中間表現を経由することによって、統一的に扱うことができるわけです。
JsonSerializerOptions.TypeInfoResolverIJsonTypeInfoResolver で、これは対象の型から、その型を表す JsonTypeInfo を取得するためのものです。

今回使った WithAddedModifier メソッドは、その IJsonTypeInfoResolver が返した JsonTypeInfo をカスタマイズするためのメソッドです。
上記のコードでは、その型情報から NonSerializable 型のプロパティを表す JsonPropertyInfo を削除することで、そのプロパティをなかったことにしているわけです。

使用上の注意

JsonSerializeOptions.TypeInfoResolver の既定値は null です。
そのため、あらかじめ何かしらを設定しておかないと、上記のサンプルはエラーになります。

.NET 8 以降では、TypeInfoResolverChain プロパティを使っているかもしれません。
このプロパティは既定では空ですが、何か追加しておけば、TypeInfoResolver も連動して非 null になります。

ソース ジェネレーターを使用する場合、以下のようにしておくことが多いでしょう。

[JsonSerializable(typeof(Data))]
partial class DataSerializerContext : JsonSerializerContext;
var options = new JsonSerializerOptions();

options.TypeResolverChain.Insert(0, DataSerializerContext.Default);

ここで JsonSerializerContextDefault プロパティが返す値も含む)は IJsonTypeInfoResolver を実装しています。
そのため、ソース ジェネレーターが生成したコードが、事前に読み取った Data 型の定義に沿って組み立てた JsonTypeInfo を返してくれるというわけです。

ところで、この TypeInfoResolverTypeInfoResolverChain の連携処理に何かバグがあったらしく、.NET 9.0 以前では StackOverflowException が発生してしまいます。そのため、このあたりの機能を安心して使えるのは .NET 10 以降となるでしょう。
github.com

また、TypeInfoResolverChain の設定順は重要です。JsonSerializerTypeInfoResolverChain に含まれているリゾルバに対して順番に GetTypeInfo メソッドを呼び出し、最初に null でない値を返したリゾルバを使用するからです。

そのため、TypeInfoResolver の既定値が null だからといって、たとえばこういうコードを書くと無効になります。

var options = new JsonSerializerOptions();
var resolver = new DefaultJsonTypeInfoResolver();

resolver = resolver.WithAddedModifier(static typeInfo =>
{
    // 省略
});

options.TypeInfoResolverChain.Add(resolver);
options.TypeInfoResolverChain.Add(DataSerializerContext.Default);

DefaultJsonTypeInfoResolver はリフレクション ベースなので AOT コンパイルでは使えませんし、それが先に追加されているため DataSerializerContext.Default は参照されません。
逆に DataSerializerContext.Default を先に追加すると、今度はモディファイアを適用したリゾルバが使われません。

そのため、やるなら前掲のように TypeInfoResolver に対して WithAddedModifier を呼び出すか、もしくは TypeInfoResolverChain に含まれているすべてのリゾルバに対して WithAddedModifier を使用する必要があるでしょう。
いずれにしても、TypeInfoResolver あるいは TypeInfoResolverChain の設定が適切に完了した後でないと WithAddedModifier は使用できないということになりそうです。

まとめ

対象の型に属性をつける以外にも、WithAddedModifier を使うことで、プロパティを足したり消したりすることができます。覚えておくと何かの役に立つかもしれません。