# JsonSerializer

> 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.

<!-- id: json-serializer · area: json · stability: stable · html: https://osysharp.com/reference/json/serializer/ -->

## Summary        {#summary}
**`JsonSerializer`** is the JSON surface, spelled exactly as in C# (`System.Text.Json`). It has two members:

```osy syntax
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.*](https://osysharp.com/reference/http/facade/): serialize a request body, deserialize a response.

## Signature      {#signature}
```osy syntax
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    {#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](#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     {#scalars}
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#:

```osy title="a JSON value read as its own type" test app=json-serializer-scalars
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](#enum-wire-names)). A `Guid`
reads its 32 hex digits, dashed or not, in either case.

### What it throws     {#json-exception}
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:

```osy title="catch what cannot be read" test app=json-serializer-exception
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:

```text
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?     {#dates}
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?     {#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](#dates).

```osy title="read and write JSON in a page" test app=json-in-a-page
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     {#json-column}
A property declared `Json` holds a document, and it is **string-backed** — so `Serialize` writes one and the property
reads back into `Deserialize`:

```osy syntax
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     {#json-member}

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.

```osy syntax
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     {#options}
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`:

```osy syntax
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?     {#depth-and-cycles}
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:

```text
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?](https://osysharp.com/reference/function/deep-object-graphs/) for how the limit is counted and why it exists.

### An enum on the wire: `[ExternalName]` on a member     {#enum-wire-names}
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`     {#enums-as-names}
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:

```osy syntax
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       {#examples}

Round-trip a DTO through JSON:

```osy title="round-trip a DTO" test app=json-serializer
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.*](https://osysharp.com/reference/http/facade/)):

```osy title="parse an HTTP response body" test app=json-serializer
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:

```osy title="nested classes and lists" test app=json-serializer
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:

```osy title="leave unset optional fields out of a request body" test app=json-serializer
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:

```osy title="a request body with a nested document" test app=json-serializer
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]]):

```osy title="an enum on the wire" test app=json-serializer-enum-wire
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       {#see-also}
- [Json](https://osysharp.com/reference/types/json/) — the `Json` property type this writes into, and when to reach for it
- [Http.*](https://osysharp.com/reference/http/facade/) — the outbound HTTP surface whose bodies this serializes / parses
- [constructor](https://osysharp.com/reference/class/constructors/) — the `class` types Serialize walks and Deserialize targets
- [How deep can an object graph get?](https://osysharp.com/reference/function/deep-object-graphs/) — how deep a value may nest before Serialize refuses it, and what happens to a cycle
