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 OrderIt 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 scalarDescription#
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 JSON | read as | answers |
|---|---|---|
"hi" | string | hi |
null | string, int?, a class, a list | null |
null | int, bool, any value type without ? | throws — it cannot be null |
5 | string | throws — a number is not a string |
"seven", 1.5, 1e2 | int | throws — an int is whole digits |
1.10 | decimal | 1.10 — every digit, and the scale |
9007199254740993 | long | that number — every digit, past 2^53 too |
{"a":1} | List<string>, string[] | throws — an object is not an array |
| text that is not JSON | anything | throws |
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 as | the text | answers |
|---|---|---|
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 page | what happens |
|---|---|
Serialize(row) — a stored entity, or a class holding one | refused 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 class | refused 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 as | throws 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 T | read 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 });DefaultIgnoreCondition | what is left out |
|---|---|
Never (the default) | nothing: every member is written, null included |
WhenWritingNull | a member whose value is null |
WhenWritingDefault | a 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 |
Always | refused, 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
Jsonproperty type this writes into, and when to reach for it - Http.* — the outbound HTTP surface whose bodies this serializes / parses
- constructor — the
classtypes 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