Page title
Section title
Generating C# from JSON Typedef schemas
JSON Type Definition, aka RFC 8927, is an easy-to-learn, standardized way to define a schema for JSON data. You can use JSON Typedef to portably validate data across programming languages, create dummy data, generate code, and more.
This article is about how you can use JSON Typedef to generate C# code from
schemas. If you’re interested in generating code in other languages, see this
article on jtd-codegen
. The rest of this article focuses
on using jtd-codegen
with C# in particular.
Generating C# with jtd-codegen
As a prerequisite, you need to first install jtd-codegen
. Installation
instructions are available here.
At time time of writing, jtd-codegen
only supports generating C# code that
uses the
System.Text.Json
JSON library. Support for Newtonsoft.Json is
planned but not yet implemented.
You can generate C# with jtd-codegen
using the --csharp-system-text-out
option, whose value must be a directory that jtd-codegen
can generate code
into. You also need to specify --csharp-system-text-namespace
, indicating the
name of the namespace jtd-codegen
should generate.
For example, if you have this schema in schemas/user.jtd.json
:
{
"properties": {
"id": { "type": "string" },
"createdAt": { "type": "timestamp" },
"karma": { "type": "int32" },
"isAdmin": { "type": "boolean" }
}
}
Then you can generate C# code into the src/user
directory, with the namespace
name Example.User
, by running:
jtd-codegen schemas/user.jtd.json --csharp-system-text-out src/user --csharp-system-text-namespace Example.User
Which will output something like:
📝 Writing C# + System.Text.Json code to: src/user
📦 Generated C# + System.Text.Json code.
📦 Root schema converted into type: User
And you should see code along these lines in src/user/User.cs
:
using System;
using System.Text.Json.Serialization;
namespace Example.User
{
public class User
{
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; set; }
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("isAdmin")]
public bool IsAdmin { get; set; }
[JsonPropertyName("karma")]
public int Karma { get; set; }
}
}
Note: at the time of writing, generated code is not always formatted in a
pretty way. If you require pretty-formatted code, it’s recommended that you use
a code formatter on jtd-codegen
-generated code.
Using generated C# code
Code generated using jtd-codegen --csharp-system-text-out
is compatible with
the the
System.Text.Json
JSON library. To use the generated types, import them and then pass them to a
System.Text.Json’s JsonSerializer
as you would usually do.
For example, we might import the generated User
class above as:
using Example.User;
And then pass it to JsonConvert
as:
// To read in JSON, do something like:
string input = "...";
User user = JsonSerializer.Deserialize<User>(input);
// To write out JSON, do something like:
string output = JsonSerializer.Serialize(user);
In the example above, we directly Deserialize
unvalidated input into the
jtd-codegen
-generated type. In many cases, this is perfectly fine to do.
However, there are two caveats when doing this:
-
The System.Text.Json package may be more lenient than you expect. For instance, System.Text.Json accepts JSON
null
forstring
fields (which is very reasonable behavior, but may be undesirable for your application). You may find yourself accepting inputs you never intended to, and this can cause challenges if users come to depend on this behavior. -
The errors System.Text.Json produces are C#-specific and relatively low-level.
You can address both of these issues by first validating the input against a JTD
validation implementation, such as the Jtd.Jtd
package. What you would do
is:
- Parse the input into a System.Text.Json
JsonDocument
, rather than the generated type. You can do this usingJsonDocument.Parse
. - Validate that the parsed
JsonDocument
is valid against the schema you generated your types from, using a JTD validation implementation. If there are validation errors, you can return those, because JTD validation errors are standardized and platform-independent. - If the input is valid, then parse the input JSON into your generated type as you usually would.
This solution lets you produce portable validation errors and lets you be more deliberate about what inputs you do and don’t accept. However, it comes at the cost of having to process the JSON input twice.
Customizing C# output
C# code generation supports the following metadata properties shared across all
languages supported by jtd-codegen
:
-
description
customizes the documentation comment to put on a type or property in generated code. For example, this schema:{ "metadata": { "description": "A user in our system" }, "properties": { "name": { "metadata": { "description": "The user's name" }, "type": "string" }, "isAdmin": { "metadata": { "description": "Whether the user is an admin" }, "type": "boolean" } } }
Generates into:
using System.Text.Json.Serialization; namespace Example { /// <summary> /// A user in our system /// </summary> public class User { /// <summary> /// Whether the user is an admin /// </summary> [JsonPropertyName("isAdmin")] public bool IsAdmin { get; set; } /// <summary> /// The user's name /// </summary> [JsonPropertyName("name")] public string Name { get; set; } } }
-
enumDescription
is likedescription
, but for the members of anenum
. The keys ofenumDescription
should correspond to the values in the schema’senum
, and the values should be descriptions for those values. For example, this schema:{ "metadata": { "enumDescription": { "PENDING": "The job is waiting to be processed.", "IN_PROGRESS": "The job is being processed.", "DONE": "The job has been processed." } }, "enum": ["PENDING", "IN_PROGRESS", "DONE"] }
Generates into:
using System; using System.Text.Json; using System.Text.Json.Serialization; namespace Example { [JsonConverter(typeof(StatusJsonConverter))] public enum Status { /// <summary> /// The job has been processed. /// </summary> Done, /// <summary> /// The job is being processed. /// </summary> InProgress, /// <summary> /// The job is waiting to be processed. /// </summary> Pending, } public class StatusJsonConverter : JsonConverter<Status> { public override Status Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { string value = JsonSerializer.Deserialize<string>(ref reader, options); switch (value) { case "DONE": return Status.Done; case "IN_PROGRESS": return Status.InProgress; case "PENDING": return Status.Pending; default: throw new ArgumentException(String.Format("Bad Status value: {0}", value)); } } public override void Write(Utf8JsonWriter writer, Status value, JsonSerializerOptions options) { switch (value) { case Status.Done: JsonSerializer.Serialize<string>(writer, "DONE", options); return; case Status.InProgress: JsonSerializer.Serialize<string>(writer, "IN_PROGRESS", options); return; case Status.Pending: JsonSerializer.Serialize<string>(writer, "PENDING", options); return; } } } }
Additionally, C# code generation supports the following C#-specific options:
-
csharpSystemTextType
overrides the type thatjtd-codegen
should generate.jtd-codegen
will not generate any code for schemas withcsharpSystemTextType
, and instead use the value ofcsharpSystemTextType
as-is.It is your responsibility to ensure that the value of
csharpSystemTextType
is valid code.jtd-codegen
will not attempt to validate its value.For example, this schema:
{ "properties": { "name": { "type": "string" }, "isAdmin": { "metadata": { "csharpSystemTextType": "MyCustomType" }, "type": "boolean" } } }
Generates into:
using System.Text.Json.Serialization; namespace Example { public class OverrideDemo { [JsonPropertyName("isAdmin")] public MyCustomType IsAdmin { get; set; } [JsonPropertyName("name")] public string Name { get; set; } } }
-
csharpSystemTextContainer
overrides the type thatjtd-codegen
uses for lists and dictionaries. By default, generated code usesSystem.Collections.Generic.IList
andSystem.Collections.Generic.IDictionary
, but you can override this withcsharpSystemTextContainer
.It is your responsibility to ensure that the value of
csharpSystemTextContainer
is valid code.jtd-codegen
will not attempt to validate its value.In particular, you should make sure your chosen type for lists supports parameterizing its value (i.e. it should be something that can be invoked as
Foo<T>
) and your type for dictionaries supportsstring
as its first value, and validT
for its second value (i.e. it should be something that can be invoked asFoo<string, T>
).For example:
{ "properties": { "example_list": { "metadata": { "csharpSystemTextContainer": "MyCustomList" }, "elements": { "type": "string" } }, "example_map": { "metadata": { "csharpSystemTextContainer": "MyCustomDictionary" }, "values": { "type": "string" } } } }
Generates into:
using System.Text.Json.Serialization; namespace Example { public class ContainerOverrides { [JsonPropertyName("example_list")] public MyCustomList<string> ExampleList { get; set; } [JsonPropertyName("example_map")] public MyCustomDictionary<string, string> ExampleMap { get; set; } } }
Generated C# code
This section details the sort of C# code that jtd-codegen
will generate.
Code generated from “Empty” schemas
“Empty” schemas will be converted into a
C# object
:
{}
Generates into:
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Example
{
[JsonConverter(typeof(EmptyJsonConverter))]
public class Empty
{
/// <summary>
/// The underlying data being wrapped.
/// </summary>
public object Value { get; set; }
}
public class EmptyJsonConverter : JsonConverter<Empty>
{
public override Empty Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return new Empty { Value = JsonSerializer.Deserialize<object>(ref reader, options) };
}
public override void Write(Utf8JsonWriter writer, Empty value, JsonSerializerOptions options)
{
JsonSerializer.Serialize<object>(writer, value.Value, options);
}
}
}
Note: jtd-codegen
had to generate a custom type alias here, which is why the
code has a bit of extra stuff. If you use “empty”, “type”, “ref”, “elements”, or
“values” schemas at the top level of a schema, jtd-codegen
has to emit type
aliases in C#. In real-world schemas, this doesn’t happen very often.
Code generated from “Ref” schemas
“Ref” schemas will be converted into a reference to the definition being referred to:
{
"definitions": {
"example": { "type": "string" }
},
"ref": "example"
}
Generates into:
// Ref.cs
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Example
{
[JsonConverter(typeof(RefJsonConverter))]
public class Ref
{
/// <summary>
/// The underlying data being wrapped.
/// </summary>
public Example Value { get; set; }
}
public class RefJsonConverter : JsonConverter<Ref>
{
public override Ref Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return new Ref { Value = JsonSerializer.Deserialize<Example>(ref reader, options) };
}
public override void Write(Utf8JsonWriter writer, Ref value, JsonSerializerOptions options)
{
JsonSerializer.Serialize<Example>(writer, value.Value, options);
}
}
}
// Example.cs
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Example
{
[JsonConverter(typeof(ExampleJsonConverter))]
public class Example
{
/// <summary>
/// The underlying data being wrapped.
/// </summary>
public string Value { get; set; }
}
public class ExampleJsonConverter : JsonConverter<Example>
{
public override Example Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return new Example { Value = JsonSerializer.Deserialize<string>(ref reader, options) };
}
public override void Write(Utf8JsonWriter writer, Example value, JsonSerializerOptions options)
{
JsonSerializer.Serialize<string>(writer, value.Value, options);
}
}
}
Note: jtd-codegen
had to generate a custom type alias here, which is why the
code has a bit of extra stuff. If you use “empty”, “type”, “ref”, “elements”, or
“values” schemas at the top level of a schema, jtd-codegen
has to emit type
aliases in C#. In real-world schemas, this doesn’t happen very often.
Code generated from “Type” schemas
“Type” schemas will be converted into the following types:
JSON Typedef type | C# type |
---|---|
boolean |
bool |
string |
string |
timestamp |
System.DateTimeOffset |
float32 |
float |
float64 |
double |
int8 |
sbyte |
uint8 |
byte |
int16 |
short |
uint16 |
ushort |
int32 |
int |
uint32 |
uint |
For example,
{
"properties": {
"boolean": { "type": "boolean" },
"string": { "type": "string" },
"timestamp": { "type": "timestamp" },
"float32": { "type": "float32" },
"float64": { "type": "float64" },
"int8": { "type": "int8" },
"uint8": { "type": "uint8" },
"int16": { "type": "int16" },
"uint16": { "type": "uint16" },
"int32": { "type": "int32" },
"uint32": { "type": "uint32" }
}
}
Generates into:
using System;
using System.Text.Json.Serialization;
namespace Example
{
public class Type_
{
[JsonPropertyName("boolean")]
public bool Boolean_ { get; set; }
[JsonPropertyName("float32")]
public float Float32 { get; set; }
[JsonPropertyName("float64")]
public double Float64 { get; set; }
[JsonPropertyName("int16")]
public short Int16_ { get; set; }
[JsonPropertyName("int32")]
public int Int32_ { get; set; }
[JsonPropertyName("int8")]
public sbyte Int8 { get; set; }
[JsonPropertyName("string")]
public string String_ { get; set; }
[JsonPropertyName("timestamp")]
public DateTimeOffset Timestamp { get; set; }
[JsonPropertyName("uint16")]
public ushort Uint16 { get; set; }
[JsonPropertyName("uint32")]
public uint Uint32 { get; set; }
[JsonPropertyName("uint8")]
public byte Uint8 { get; set; }
}
}
Code generated from “Enum” schemas
“Enum” schemas will be converted into a
C# enum, with a custom Serializer
that will handle converting to/from JSON
strings:
{
"enum": ["PENDING", "IN_PROGRESS", "DONE"]
}
Generates into:
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Example
{
[JsonConverter(typeof(Enum_JsonConverter))]
public enum Enum_
{
Done,
InProgress,
Pending,
}
public class Enum_JsonConverter : JsonConverter<Enum_>
{
public override Enum_ Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
string value = JsonSerializer.Deserialize<string>(ref reader, options);
switch (value)
{
case "DONE":
return Enum_.Done;
case "IN_PROGRESS":
return Enum_.InProgress;
case "PENDING":
return Enum_.Pending;
default:
throw new ArgumentException(String.Format("Bad Enum_ value: {0}", value));
}
}
public override void Write(Utf8JsonWriter writer, Enum_ value, JsonSerializerOptions options)
{
switch (value)
{
case Enum_.Done:
JsonSerializer.Serialize<string>(writer, "DONE", options);
return;
case Enum_.InProgress:
JsonSerializer.Serialize<string>(writer, "IN_PROGRESS", options);
return;
case Enum_.Pending:
JsonSerializer.Serialize<string>(writer, "PENDING", options);
return;
}
}
}
}
Code generated from “Elements” schemas
“Elements” schemas will be converted
into a C# System.Collections.Generic.IList<T>
, where T
is the type of the
elements of the array:
{
"elements": {
"type": "string"
}
}
Generates into:
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Example
{
[JsonConverter(typeof(ElementsJsonConverter))]
public class Elements
{
/// <summary>
/// The underlying data being wrapped.
/// </summary>
public IList<string> Value { get; set; }
}
public class ElementsJsonConverter : JsonConverter<Elements>
{
public override Elements Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return new Elements { Value = JsonSerializer.Deserialize<IList<string>>(ref reader, options) };
}
public override void Write(Utf8JsonWriter writer, Elements value, JsonSerializerOptions options)
{
JsonSerializer.Serialize<IList<string>>(writer, value.Value, options);
}
}
}
Note: jtd-codegen
had to generate a custom type alias here, which is why the
code has a bit of extra stuff. If you use “empty”, “type”, “ref”, “elements”, or
“values” schemas at the top level of a schema, jtd-codegen
has to emit type
aliases in C#. In real-world schemas, this doesn’t happen very often.
Code generated from “Properties” schemas
“Properties” schemas will be
converted into a C# POCO. Optional properties will be annotated with
[JsonIgnore]
with condition WhenWritingDefault
, which means that they will
be omitted from JSON if set to null
.
{
"properties": {
"name": { "type": "string" },
"isAdmin": { "type": "boolean" }
},
"optionalProperties": {
"middleName": { "type": "string" }
},
"additionalProperties": true
}
Generates into:
using System.Text.Json.Serialization;
namespace Example
{
public class Properties
{
[JsonPropertyName("isAdmin")]
public bool IsAdmin { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("middleName")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public string MiddleName { get; set; }
}
}
Code generated from “Values” schemas
“Values” schemas will be converted into
a C# System.Collections.Generic.IDictionary<string, T>
, where T
is the type
of the values of the object:
{
"values": {
"type": "string"
}
}
Generates into:
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Example
{
[JsonConverter(typeof(ValuesJsonConverter))]
public class Values
{
/// <summary>
/// The underlying data being wrapped.
/// </summary>
public IDictionary<string, string> Value { get; set; }
}
public class ValuesJsonConverter : JsonConverter<Values>
{
public override Values Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return new Values { Value = JsonSerializer.Deserialize<IDictionary<string, string>>(ref reader, options) };
}
public override void Write(Utf8JsonWriter writer, Values value, JsonSerializerOptions options)
{
JsonSerializer.Serialize<IDictionary<string, string>>(writer, value.Value, options);
}
}
}
Note: jtd-codegen
had to generate a custom type alias here, which is why the
code has a bit of extra stuff. If you use “empty”, “type”, “ref”, “elements”, or
“values” schemas at the top level of a schema, jtd-codegen
has to emit type
aliases in C#. In real-world schemas, this doesn’t happen very often.
Code generated from “Discriminator” schemas
“Discriminator” schemas will be converted into an abstract class, and each mapping will be a concrete implementation of that class. The abstract class will have a custom serializer that can the “tag” property to figure out which instance to create:
{
"discriminator": "eventType",
"mapping": {
"USER_CREATED": {
"properties": {
"id": { "type": "string" }
}
},
"USER_PAYMENT_PLAN_CHANGED": {
"properties": {
"id": { "type": "string" },
"plan": { "enum": ["FREE", "PAID"] }
}
},
"USER_DELETED": {
"properties": {
"id": { "type": "string" },
"softDelete": { "type": "boolean" }
}
}
}
}
Generates into:
// Discriminator.cs
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Example
{
[JsonConverter(typeof(DiscriminatorJsonConverter))]
public abstract class Discriminator
{
}
public class DiscriminatorJsonConverter : JsonConverter<Discriminator>
{
public override Discriminator Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var readerCopy = reader;
var tagValue = JsonDocument.ParseValue(ref reader).RootElement.GetProperty("eventType").GetString();
switch (tagValue)
{
case "USER_CREATED":
return JsonSerializer.Deserialize<DiscriminatorUserCreated>(ref readerCopy, options);
case "USER_DELETED":
return JsonSerializer.Deserialize<DiscriminatorUserDeleted>(ref readerCopy, options);
case "USER_PAYMENT_PLAN_CHANGED":
return JsonSerializer.Deserialize<DiscriminatorUserPaymentPlanChanged>(ref readerCopy, options);
default:
throw new ArgumentException(String.Format("Bad EventType value: {0}", tagValue));
}
}
public override void Write(Utf8JsonWriter writer, Discriminator value, JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, value, value.GetType(), options);
}
}
}
// DiscriminatorUserCreated.cs
using System.Text.Json.Serialization;
namespace Example
{
public class DiscriminatorUserCreated : Discriminator
{
[JsonPropertyName("eventType")]
public string EventType { get => "USER_CREATED"; }
[JsonPropertyName("id")]
public string Id { get; set; }
}
}
// DiscriminatorUserDeleted.cs
using System.Text.Json.Serialization;
namespace Example
{
public class DiscriminatorUserDeleted : Discriminator
{
[JsonPropertyName("eventType")]
public string EventType { get => "USER_DELETED"; }
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("softDelete")]
public bool SoftDelete { get; set; }
}
}
// DiscriminatorUserPaymentPlanChanged.cs
using System.Text.Json.Serialization;
namespace Example
{
public class DiscriminatorUserPaymentPlanChanged : Discriminator
{
[JsonPropertyName("eventType")]
public string EventType { get => "USER_PAYMENT_PLAN_CHANGED"; }
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("plan")]
public DiscriminatorUserPaymentPlanChangedPlan Plan { get; set; }
}
}
// DiscriminatorUserPaymentPlanChangedPlan.cs
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Example
{
[JsonConverter(typeof(DiscriminatorUserPaymentPlanChangedPlanJsonConverter))]
public enum DiscriminatorUserPaymentPlanChangedPlan
{
Free,
Paid,
}
public class DiscriminatorUserPaymentPlanChangedPlanJsonConverter : JsonConverter<DiscriminatorUserPaymentPlanChangedPlan>
{
public override DiscriminatorUserPaymentPlanChangedPlan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
string value = JsonSerializer.Deserialize<string>(ref reader, options);
switch (value)
{
case "FREE":
return DiscriminatorUserPaymentPlanChangedPlan.Free;
case "PAID":
return DiscriminatorUserPaymentPlanChangedPlan.Paid;
default:
throw new ArgumentException(String.Format("Bad DiscriminatorUserPaymentPlanChangedPlan value: {0}", value));
}
}
public override void Write(Utf8JsonWriter writer, DiscriminatorUserPaymentPlanChangedPlan value, JsonSerializerOptions options)
{
switch (value)
{
case DiscriminatorUserPaymentPlanChangedPlan.Free:
JsonSerializer.Serialize<string>(writer, "FREE", options);
return;
case DiscriminatorUserPaymentPlanChangedPlan.Paid:
JsonSerializer.Serialize<string>(writer, "PAID", options);
return;
}
}
}
}
Section title
-
-
-
-
Tooling
-
Advanced Concepts
-
Language-Specific Documentation