Osy#betaRun it on Osyrin: osyrin.com

Reference / JSON

Json.SchemaOf<T>() — the JSON Schema of a type

string Json.SchemaOf<T>()

The JSON Schema of an Osy# type, as text: a class is an object of its members, an enum is its members by name, a list is an array, a nullable member may be null. It describes exactly the document `JsonSerializer.Deserialize<T>` reads, so what a model, a tool or another system writes to it is something your code can read.

preview1 example compiled by CIjsonschemabcl

Summary#

Json.SchemaOf<T>() answers the JSON Schema (2020-12) of T, as a string:

string schema = Json.SchemaOf<Quote>();          // {"type":"object","properties":{…},"required":[…],…}

You write the type once, as a class, and never write its schema beside it. Hand the schema to whatever will write the JSON — a model asked for a structured answer, a tool's description, another system — and read what comes back with JsonSerializer: JsonSerializer.Deserialize<Quote>(text).

It is pure — no capability, no using.

Signature#

string Json.SchemaOf<T>()     // T = a class, an enum, an entity, a built-in type,
                              //     or a List<T>, T[], HashSet<T> or Dictionary<string, T> of one

Description#

What each type becomes#

The typeIts schema
a class{"type":"object","properties":{…},"required":[…],"additionalProperties":false} — one property per member, in the order declared
an enum{"type":"string","enum":["Low","High"]} — its members by name, or by [ExternalName] where one is declared
a [Flags] enum{"type":"integer"} — a set of flags is a number; the description lists what each bit means
List<T>, T[]{"type":"array","items":…}
HashSet<T>the same, with "uniqueItems":true
Dictionary<string, T>{"type":"object","additionalProperties":…}
string{"type":"string"} — with maxLength where the member declares [MaxLength]
int · long{"type":"integer"} · the same with "format":"int64"
decimal, double{"type":"number"}
bool{"type":"boolean"}
DateTime · DateOnly · TimeOnlya string with "format" date-time · date · time
TimeSpana string as [d.]hh:mm:ss
Guida string with "format":"uuid"
byte[]a string with "contentEncoding":"base64"
Json{} — any JSON may stand there
an entityan object of its id and its own values; a reference is the other row's id; its collections are left out

A member that can be null says so in its type — {"type":["string","null"]} — and is not required. Every other member is required. A member can be null when it is declared T? (string? Note, Urgency? Fallback), and also when its type has no value that stands for "not set": a class, a DateTime, a Guid. Declare such a member required (public required Address ShipTo;) to say it is always there.

Names are the serializer's. A member is named as JsonSerializer reads and writes it: its [ExternalName] where it declares one, its own name otherwise. So a document that satisfies the schema deserializes.

Descriptions are your own words. A /// comment on a class or a member becomes that part's description, and an enum's is its members' comments (Low: Whenever there is time. High: Before the day ends.). A model reads them, so a sentence saying what a member is FOR is worth writing.

A class inside itself#

A class that contains itself is written once and referred to. At the root it refers to itself as #:

{"type":"object","properties":{"Name":{"type":"string"},"Children":{"type":"array","items":{"$ref":"#"}}}, …}

Anywhere else it is one definition under $defs, and every place it stands is {"$ref":"#/$defs/Folder"}.

In a generic function#

Json.SchemaOf<T>() works on a generic function's own type parameter, which is how a helper that asks for any shape is written once:

T Ask<T>(string question) {
  var answer = Model.Complete(question, schema: Json.SchemaOf<T>());   // your own call to a model
  return JsonSerializer.Deserialize<T>(answer);
}

The type is the one each call passes — Ask<Quote>(…), Ask<Urgency>(…) — and the answer is the same text as naming it directly. JsonSerializer.Deserialize<T> reads the same T.

A generic class reads its own type parameter the same way, in its instance members — the type the instance was built over, so a subclass that closes the generic (class Measure : Job<string, Reading>) answers for Reading:

class Job<TIn, TOut> {
  public string Shape() { return Json.SchemaOf<TOut>(); }
  public TOut Read(string json) { return JsonSerializer.Deserialize<TOut>(json); }
}

Two limits, both said where they are met:

  • The type must be a named one: a class, an enum, an entity or a built-in. For a list, name it where it is known (Json.SchemaOf<List<Quote>>()) or wrap it in a class.
  • A static member of a generic class has no instance to read the type from. Take it as the member's own type parameter instead.

On the client#

A schema of a type the call names is worked out when the app is compiled, so it is the same text in a function that runs in the browser as in one that runs on the server. The generic form reads the app's model, so a function using it runs on the server.

What has no schema#

A type that is not data is refused where it is written, naming what can be described:

`Json.SchemaOf<Func<int>>()`: 'Func<int>' is not data, so JSON cannot carry it. A schema describes a class, an enum,
an entity, a built-in type (string, int, long, decimal, double, bool, DateTime, DateOnly, TimeOnly, TimeSpan, Guid,
byte[], Json), or a `List<T>`, `T[]`, `HashSet<T>` or `Dictionary<string, T>` of one.

A dictionary must be keyed by string — a JSON object's member names are text.

Examples#

Describe a type, and read what was written to it#

public enum Urgency {
  /// Whenever there is time.
  Low,
  /// Before the day ends.
  High
}

public enum Unit { [ExternalName("kg")] Kilogram, [ExternalName("pcs")] Piece }

/// One priced line.
public class QuoteLine {
  /// What is being priced.
  public string Item = "";
  public decimal Price;
  public int Quantity;
  public Unit Unit;
}

/// A priced offer.
public class Quote {
  /// Who it is for.
  [ExternalName("customer_name")] public string Customer = "";
  public string? Note;
  public Urgency Urgency;
  public List<QuoteLine> Lines = [];
}

/// The schema to hand to whatever writes the JSON.
public string QuoteSchema() { return Json.SchemaOf<Quote>(); }

/// The same, for any type a caller names: `SchemaFor<Quote>()`, `SchemaFor<Urgency>()`.
public string SchemaFor<T>() { return Json.SchemaOf<T>(); }

/// What comes back, read as the type — `{"customer_name":"Ada","Urgency":"High","Lines":[…]}`.
public decimal Total(string json) {
  var quote = JsonSerializer.Deserialize<Quote>(json);
  decimal total = 0;
  foreach (var line in quote.Lines) { total = total + line.Price * line.Quantity; }
  return total;
}

QuoteSchema() answers:

{
  "type": "object",
  "description": "A priced offer.",
  "properties": {
    "customer_name": { "type": "string", "description": "Who it is for." },
    "Note": { "type": ["string", "null"] },
    "Urgency": {
      "type": "string",
      "enum": ["Low", "High"],
      "description": "Low: Whenever there is time. High: Before the day ends."
    },
    "Lines": {
      "type": "array",
      "items": {
        "type": "object",
        "description": "One priced line.",
        "properties": {
          "Item": { "type": "string", "description": "What is being priced." },
          "Price": { "type": "number" },
          "Quantity": { "type": "integer" },
          "Unit": { "type": "string", "enum": ["kg", "pcs"] }
        },
        "required": ["Item", "Price", "Quantity", "Unit"],
        "additionalProperties": false
      }
    }
  },
  "required": ["customer_name", "Urgency", "Lines"],
  "additionalProperties": false
}

See also#

Related

JsonSerializer

Turn a value into a JSON string and a JSON string into a typed object — the C#-faithful System.Text.Json spelling…

[Action] — what an app offers to be done, by name

Mark a function `[Action]` and the app offers it to be done BY NAME — by a rule, a macro, a tool — with everything a…

Json

A member that holds a JSON document — an object, an array, or any value. It is stored as real jsonb, and written and…