Osy#betaRun it on Osyrin: osyrin.com

Reference / JSON

JsonSerializer

string JsonSerializer.Serialize(value) · string JsonSerializer.Serialize(value, options) · T JsonSerializer.Deserialize<T>(json)

Turn a value into a JSON string and a JSON string into a typed object — the C#-faithful System.Text.Json spelling. `JsonSerializer.Serialize(order)` gives the JSON text; `JsonSerializer.Deserialize<Order>(body)` parses it back into a typed `Order`. Pure — no capability needed, and it runs where it is written: in a function on the server and in a page in the browser, with the same answer.

stable9 examples compiled by CIjsonserializationbcl

Summary#

JsonSerializer is the JSON surface, spelled exactly as in C# (System.Text.Json). It has two members:

var body = JsonSerializer.Serialize(order);              // a value → a JSON string
var order = JsonSerializer.Deserialize<Order>(body);     // a JSON string → a typed Order

It is pure — no capability, no using required (though a pasted using System.Text.Json; is accepted and does nothing). It pairs naturally with Http.*: serialize a request body, deserialize a response.

Signature#

string JsonSerializer.Serialize(value)          // value = a scalar, a class, a list, or a map
string JsonSerializer.Serialize(value, JsonSerializerOptions options)
T      JsonSerializer.Deserialize<T>(string json) // T = a class, a collection, or a scalar

Description#

Serialize accepts any value and returns compact JSON (matching System.Text.Json's default): a scalar ("x", 42, true, 3.5), a class instance (→ a JSON object of its fields), a list (→ an array), a map (→ an object). Property names are the field names as declared. byte[] becomes base64.

Deserialize<T> parses JSON into a T, where T is a class — the DTO you want the data as. It fills each declared field from the matching JSON member, coercing by the field's type: scalars, a nested class (→ a nested object), and a collection (→ a list of elements). A member missing from the JSON is left at its default; a JSON null sets null. A field the class doesn't declare is ignored.

T may also be a collection — Dictionary<K,V>, List<T>, HashSet<T>, an array — or a scalar: string, bool, int, long, decimal, double, Guid, DateTime, DateOnly, TimeOnly, TimeSpan, an enum, and the nullable form of each (int?). See #scalars.

T is never an entity — an entity has identity and persistence a JSON body can't carry. That is refused where it is compiled, in a function and in a page alike.

Reading one value: a scalar, a list, an array#

A value kept as JSON text — one argument of a recorded call, one answer from a service — is read back as its own type, exactly as in C#:

string? Title(string json)    { return JsonSerializer.Deserialize<string>(json); }    // "\"Hello\""  → Hello
int Count(string json)        { return JsonSerializer.Deserialize<int>(json); }       // "41"          → 41
int? MaybeCount(string json)  { return JsonSerializer.Deserialize<int?>(json); }      // "null"        → null
decimal Price(string json)    { return JsonSerializer.Deserialize<decimal>(json); }   // "1.25"        → 1.25
bool Done(string json)        { return JsonSerializer.Deserialize<bool>(json); }      // "true"        → true
Guid Key(string json)         { return JsonSerializer.Deserialize<Guid>(json); }      // "\"6f96…\""   → that Guid
DateTime At(string json)      { return JsonSerializer.Deserialize<DateTime>(json); }  // "\"2026-03-04T05:06:07Z\""
List<string> Tags(string json) { return JsonSerializer.Deserialize<List<string>>(json)!; }   // "[\"a\",\"b\"]"
int[] Scores(string json)      { return JsonSerializer.Deserialize<int[]>(json)!; }          // "[1,2,3]"
the JSONread asanswers
"hi"stringhi
nullstring, int?, a class, a listnull
nullint, bool, any value type without ?throws — it cannot be null
5stringthrows — a number is not a string
"seven", 1.5, 1e2intthrows — an int is whole digits
1.10decimal1.10 — every digit, and the scale
9007199254740993longthat number — every digit, past 2^53 too
{"a":1}List<string>, string[]throws — an object is not an array
text that is not JSONanythingthrows

An enum reads its number, or the name [ExternalName] gives a member (#enum-wire-names). A Guid reads its 32 hex digits, dashed or not, in either case.

What it throws#

Everything Deserialize cannot read is a JsonException, as in C# — text that is not JSON, a value of the wrong kind, a null for a value type, a name no member of an enum has:

int CountOr(string json, int fallback) {
  try {
    return JsonSerializer.Deserialize<int>(json);
  } catch (JsonException e) {
    Log.Warning("not a count: {Why}", e.Message);
    return fallback;
  }
}

The message names the value as the body wrote it and the type asked for:

JsonSerializer.Deserialize: "seven" cannot be read as `int`.
JsonSerializer.Deserialize<int>: the JSON is `null`, and int cannot be null. Read it as `int?` to accept null.
JsonSerializer.Deserialize: "Grey" is not a value of enum 'Shade' — it accepts "Light", "Dark".
JsonSerializer.Deserialize: the text is not JSON — …

A null passed as the text itself is an ArgumentNullException, as in C#. Serialize given a double that is NaN or infinite throws an ArgumentException: JSON has no way to write one.

How are dates and times written?#

A date is text in JSON, in ISO 8601: 2026-03-04, then optionally T05:06, seconds, a fraction, and Z or an offset (+02:00, +02). What a text is read as does not depend on the machine reading it:

read asthe textanswers
DateTime"2026-03-04T05:06:07Z"that instant, UTC
DateTime"2026-03-04T05:06:07+02:00"the INSTANT it names, as UTC — 2026-03-04T03:06:07Z
DateTime"2026-03-04T05:06:07", "2026-03-04"as written, with no zone
DateOnly"2026-03-04", "2026-03-04T23:30:00-05:00"the date AS WRITTEN — the 4th, whatever the offset
TimeOnly"10:30", "10:30:00", "10:30:00.5", "2026-03-04T10:30:00+02:00"the time AS WRITTEN — 10:30
TimeSpan"01:30:00", "1.02:03:04", "-00:00:01"that duration ([d.]hh:mm:ss[.fffffff])

Seven digits of a fraction are kept — a DateTime is exact to 100 nanoseconds. Serialize writes the same forms: a UTC DateTime with Z, one with no zone without it, a fraction only as far as it is not zero.

Does it work in a page?#

Both run in the browser when a component's action, a class method called from one, or a render expression uses them — no call to the server, and the same answer the server gives: the same JSON written, the same value read, the same JsonException with the same words. A decimal is read from its own digits and keeps its scale, a long keeps every digit, and a date is read by the rules above.

using Osysharp.Ui;

class Draft { public string Title = ""; public decimal Price; public List<string> Tags; }

[AllowAnonymous]
[Route("/")]
component DraftPage() {
  string text = "{\"Title\":\"Tea\",\"Price\":1.10,\"Tags\":[\"new\"]}";
  string shown = "";
  action Read() {
    try {
      var draft = JsonSerializer.Deserialize<Draft>(text)!;   // in the browser
      draft.Price = draft.Price * 2;                           // 2.20 — a decimal, with its scale
      shown = JsonSerializer.Serialize(draft);                 // {"Title":"Tea","Price":2.20,"Tags":["new"]}
    } catch (JsonException e) {
      shown = e.Message;
    }
  }
  render {
    Stack() {
      Field("Json", value: text);
      Button("Read", onPress: Read);
      Text(shown);
    }
  }
}

What is still done on the server, and what the compiler says when a page asks for it:

in a pagewhat happens
Serialize(row) — a stored entity, or a class holding onerefused where it is compiled: a row's members are read on the server, as the person reading. Serialize it in a top-level function, or copy the members you need into a class
Serialize(shape) where shape is declared as an interface or an abstract classrefused the same way: which class the value is decides its members
Serialize(animal) where the value turns out to be a subclass of the class it was declared asthrows where it runs, naming the subclass: the page was told of the declared class
a class member that is a collection of collections of a class (List<List<Cell>>)refused where it is compiled; a list of classes, and a list of lists of numbers or text, are read
Deserialize<T> over a generic function's own Tread on the server (the action waits for it); in a render, refused

One difference in wording remains: for a text that is not JSON at all, both sides say the text is not JSON — and each then says where in its own words.

Storing it: the Json property type#

A property declared Json holds a document, and it is string-backed — so Serialize writes one and the property reads back into Deserialize:

class Detail { public int Attempt; public string Reason; }

entity Job { [Required, MaxLength(50)] string Name; Json Detail; }

void Record() {
  var j = new Job { Name = "j1", Detail = JsonSerializer.Serialize(new Detail { Attempt = 2, Reason = "retry" }) };
}

int Attempts(Job job) {
  return JsonSerializer.Deserialize<Detail>(job.Detail).Attempt;
}

That is the whole surface: a Json property takes a string and gives one back. It is not parsed or validated on the way in — the same rule a Markdown property follows — so Serialize is how you sensibly produce one rather than building the text by hand.

An anonymous object is a Json value, so Detail = new { attempt = 2 } is also a document — use it for a shape that exists only at the point you build it, and a class for one your code names and reads back.

A Json MEMBER of a class is a nested document, in both directions#

A member declared Json is spliced in when the class is serialized, not quoted — and it binds the document that stood there when the class is deserialized. That is what makes a typed request body able to carry a part it does not model: a JSON Schema, a provider's arguments, a payload that passes straight through.

class Tool {
  [ExternalName("name")] public string Name = "";
  [ExternalName("parameters")] public Json? Parameters;      // a DOCUMENT, not text that looks like one
}

class ToolRequest {
  [ExternalName("tools")] public List<Json>? Tools;          // and a LIST of them is an array of documents
}

Only the DECLARED type can decide this: a Json value at runtime is a string, so nothing about the value distinguishes a document from ordinary text. The same rule applies to a List<Json> member, a Dictionary<string, Json> member, and a Json member of an anonymous object.

⚠ A Json member cannot have a default value (public Json Parameters = "{}"; is refused at the declaration). Declare it nullable and set it where you build the object.

Serializing an entity produces a shallow object: its Id and scalar properties, with an EntityRef rendered as its FK id (not the nested entity) and collections omitted. This is deliberate — expanding relations by default would invite reference cycles and load a record's whole object graph. When you want a specific nested shape, map the entity into a class DTO (which serializes fully) and serialize that.

Leaving members out, and indenting#

Pass a JsonSerializerOptions to choose what is written, exactly as in C#. The usual reason is a request body with optional fields, which an API expects to be absent rather than null:

class Request {
  [ExternalName("statement")] public string Sql = "";
  [ExternalName("role")] public string? Role;          // optional: not sent while it is null
}

var body = JsonSerializer.Serialize(request,
    new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull });
DefaultIgnoreConditionwhat is left out
Never (the default)nothing: every member is written, null included
WhenWritingNulla member whose value is null
WhenWritingDefaulta member holding its declared type's default — null, 0, false, an empty Guid, a zero date. A nullable member is left out only when it is null
Alwaysrefused, as C# refuses it here: it would leave every member out

The condition applies to the members of a class, an anonymous object and an entity, at every level of the value. A dictionary's entries are always written. WriteIndented = true spreads the JSON across indented lines.

How deep may a value nest, and what about a cycle?#

Nesting is capped at 500 levels — a class whose field is a class whose field is a class, a list of lists, a map of maps. Past that, Serialize refuses with an error naming the depth, the limit and the class it stopped inside. The cap is on nesting only: a list of a million flat items is depth 2 and serializes fine.

A value that refers back to itself is refused too, with its own message rather than a depth one — JSON has no way to write a reference to a value it has already written, so there is nothing faithful to produce:

JsonSerializer.Serialize cannot serialize an instance of class 'Node', because it refers back to itself — JSON has
no way to write a reference to a value it has already written. Break the cycle before serializing (drop the
back-pointer, or serialize the id instead of the object).

The same value reached twice down different branches is not a cycle: it is written out once per occurrence, as you would expect. See How deep can an object graph get? for how the limit is counted and why it exists.

An enum on the wire: [ExternalName] on a member#

An API usually spells its closed sets its own way — "application/pdf", "kg", "in_progress" — and a member name cannot. Put the API's spelling on the member with [ExternalName("…")], the attribute that already names a field's JSON key. This is C#'s [JsonStringEnumMemberName]:

  • an enum with any [ExternalName] goes through JSON as strings — each member as its [ExternalName], and a member without one as its own name;
  • reading accepts the wire spelling, then the member name in any case;
  • a value that is no member throws, naming the value, the enum and every value it accepts — it never becomes a default member the API did not send.

An enum with no [ExternalName] keeps the form it has always had (its stored key). Two members may not share one wire spelling — a read could not say which was meant — and the compiler refuses it.

Every enum by its name: EnumsAsNames#

A document another system reads, such as a webhook or an export, should say "High" rather than 2, because the receiver cannot know your numbering. Ask for names for the whole document instead of marking every enum:

var body = JsonSerializer.Serialize(ticket, new JsonSerializerOptions { EnumsAsNames = true });
// {"Number":1042,"Priority":"High","History":["Low","High"]}   — without it: "Priority":2, "History":[0,2]

Each member is written as its name (its [ExternalName], when it has one), in a single member and in a list alike. This is C#'s JsonStringEnumConverter. It is off by default, because a value you store is its key. Deserialize reads a name or a key whichever way the document was written, so the same class reads both forms.

Examples#

Round-trip a DTO through JSON:

class Order {
  public string Code;
  public decimal Total;
  public bool Paid;
}

string ToJson(Order o) {
  return JsonSerializer.Serialize(o);              // {"Code":"A1","Total":42.0,"Paid":true}
}

Order FromJson(string body) {
  return JsonSerializer.Deserialize<Order>(body);  // typed Order, fields populated
}

Parse an HTTP response body (Http.*):

app Shop {
  model "model/**/*.osy";
  use Osysharp.Http;       // `use` is a manifest declaration — Http.* needs it
}

class Weather { public decimal TempC; public string Summary; }

Weather Fetch(string city) {
  var r = Http.Get("https://api.example.com/weather/" + city);
  return r.IsSuccess ? JsonSerializer.Deserialize<Weather>(r.Body) : new Weather { Summary = "unknown" };
}

Nested classes and lists deserialize recursively:

class Line { public string Sku; public int Qty; }
class Cart { public string Owner; public List<Line> Lines; }

Cart Parse(string body) {
  return JsonSerializer.Deserialize<Cart>(body);   // Lines becomes a list of typed Line objects
}

Leave unset optional fields out of a request body:

class WarehouseRequest {
  public string Statement = "";
  public string? Warehouse;
}

string Body(WarehouseRequest q) {
  return JsonSerializer.Serialize(q,
    new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull });
}

A typed request body carrying a document it does not model — the shape every provider API needs:

class ToolSpec {
  [ExternalName("name")] public string Name = "";
  [ExternalName("parameters")] public Json? Parameters;
}

class CompletionRequest {
  [ExternalName("model")] public string Model = "";
  [ExternalName("tools")] public List<ToolSpec>? Tools;
  [ExternalName("temperature")] public decimal? Temperature;
}

string Ask(string model, string schemaJson) {
  var body = new CompletionRequest {
    Model = model,
    Tools = [ new ToolSpec { Name = "search", Parameters = schemaJson } ],
  };
  // → {"model":"…","tools":[{"name":"search","parameters":{"type":"object"}}]}
  // `parameters` is the schema DOCUMENT, and `temperature` is absent rather than null.
  return JsonSerializer.Serialize(body,
    new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull });
}

An enum spelled the way an external API spells it ([[#enum-wire-names]]):

enum LabelFormat {
  [ExternalName("application/pdf")] ApplicationPdf,
  [ExternalName("image/png")] ImagePng,
  Zpl
}

class LabelRequest { public LabelFormat Format; public int Dpi; }

string Body() {
  // {"Format":"application/pdf","Dpi":72} — and Deserialize<LabelRequest> reads it back into LabelFormat.ApplicationPdf
  return JsonSerializer.Serialize(new LabelRequest { Format = LabelFormat.ApplicationPdf, Dpi = 72 });
}

See also#

  • Json — the Json property type this writes into, and when to reach for it
  • Http.* — the outbound HTTP surface whose bodies this serializes / parses
  • constructor — the class types Serialize walks and Deserialize targets
  • How deep can an object graph get? — how deep a value may nest before Serialize refuses it, and what happens to a cycle

Related

Http.*

Make an outbound HTTP call to a URL you build at runtime — a webhook, a third-party API, a discovered endpoint…

constructor

A class declares one constructor — its name is the class name, it takes no return type, and it runs when you write new…

Json

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

How deep can an object graph get?

A value built out of references to other values — a linked list, a tree, a chain of parents — may nest up to 500…