Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,10 @@ const result = userDecoder.decode({

if (!result.isOk()) {
result.issues.forEach(issue => {
console.log(`${issue.path.join('.')}: ${issue.message}`);
console.log(`${JsonDecoder.formatIssuePath(issue.path)}: ${issue.message}`);
});
// id: "not-a-number" is not a valid number
// roles.1: 42 is not a valid string
// roles[1]: 42 is not a valid string
}
```

Expand Down
49 changes: 37 additions & 12 deletions assets/documents/advanced-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,24 +104,41 @@ treeDecoder.decode(badTree);

## Union Types and Type Discrimination

Handle different object shapes based on a discriminator field:
For composite types, pick the combinator that matches the shape:

| You want… | Use |
| --------------------------------------------------------- | ----------------------------------- |
| One of several alternatives, tried in order | `oneOf([a, b, …])` |
| A tagged union of objects (shared literal field) | `discriminatedUnion('kind', { … })` |
| To merge several object decoders (intersection / `A & B`) | `allOf([a, b, …])` |
| A value that may be `null` | `nullable(decoder)` |
| A value that may be `undefined` | `optional(decoder)` |

`oneOf` is the general union: it returns the first match and, on failure, reports every
alternative. Prefer `nullable`/`optional` over `oneOf([x, null])`/`oneOf([x, undefined])`.

### Tagged unions: `discriminatedUnion`

When your variants are objects sharing a literal "tag" field, reach for
`discriminatedUnion`. Knowing the tag field, it validates only the matching variant and
produces precise, single-variant errors — rather than `oneOf`, which would try every
branch and report all of their failures.

```typescript
type Shape = { type: 'circle'; radius: number } | { type: 'rectangle'; width: number; height: number };

const circleDecoder = JsonDecoder.object<Extract<Shape, { type: 'circle' }>>({
type: JsonDecoder.literal('circle'),
radius: JsonDecoder.number()
});

const rectangleDecoder = JsonDecoder.object<Extract<Shape, { type: 'rectangle' }>>({
type: JsonDecoder.literal('rectangle'),
width: JsonDecoder.number(),
height: JsonDecoder.number()
const shapeDecoder = JsonDecoder.discriminatedUnion('type', {
circle: JsonDecoder.object<Extract<Shape, { type: 'circle' }>>({
type: JsonDecoder.literal('circle'),
radius: JsonDecoder.number()
}),
rectangle: JsonDecoder.object<Extract<Shape, { type: 'rectangle' }>>({
type: JsonDecoder.literal('rectangle'),
width: JsonDecoder.number(),
height: JsonDecoder.number()
})
});

const shapeDecoder = JsonDecoder.oneOf<Shape>([circleDecoder, rectangleDecoder]);

// Usage
const shapes = [
{ type: 'circle', radius: 5 },
Expand All @@ -141,6 +158,14 @@ console.log(
})
)
); // Ok({ value: ["Circle area: 78.53981633974483", "Rectangle area: 200"] })

// A wrong field reports only the matching variant's failure:
shapeDecoder.decode({ type: 'circle', radius: 'big' });
// Err -> radius: "big" is not a valid number

// An unknown tag lists the expected values:
shapeDecoder.decode({ type: 'triangle' });
// Err -> type: "type" must be one of "circle", "rectangle", but got "triangle"
```

## Complex Transformations
Expand Down
32 changes: 31 additions & 1 deletion assets/documents/basic-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,36 @@ isActiveDecoder.decode(true); // Ok({ value: true })
isActiveDecoder.decode('true'); // Err({ issues: [{ message: '"true" is not a valid boolean', path: [] }] })
```

## Enums

Decode against a TypeScript `enum` with `enumeration`. A rejected value is
formatted with `JSON.stringify`, just like the other primitive decoders, so the
quoting depends on the value's type:

```typescript
enum Color {
Red = 'red',
Blue = 'blue'
}

const colorDecoder = JsonDecoder.enumeration<Color>(Color);
colorDecoder.decode('red'); // Ok({ value: 'red' })

// A string value keeps its quotes...
colorDecoder.decode('green');
// Err({ issues: [{ message: '"green" is not a valid enum value', path: [] }] })

// ...while a numeric value is rendered without quotes.
enum Priority {
Low = 1,
High = 2
}

const priorityDecoder = JsonDecoder.enumeration<Priority>(Priority);
priorityDecoder.decode(3);
// Err({ issues: [{ message: '3 is not a valid enum value', path: [] }] })
```

## Object Decoding

Most of the time, you'll work with objects. Here's how to decode them:
Expand Down Expand Up @@ -196,7 +226,7 @@ When a decode fails, the `Err` result holds an `issues` array. Each entry contai
const result = userDecoder.decode({ id: 'bad', name: 42, email: 'john@example.com' });
if (!result.isOk()) {
result.issues.forEach(issue => {
const location = issue.path.length > 0 ? issue.path.join('.') : 'root';
const location = issue.path.length > 0 ? JsonDecoder.formatIssuePath(issue.path) : 'root';
console.log(`${location}: ${issue.message}`);
// id: "bad" is not a valid number
// name: 42 is not a valid string
Expand Down
58 changes: 42 additions & 16 deletions assets/documents/v4-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ The public API surface is otherwise unchanged: decoders are still functions, the
- [New Capabilities](#new-capabilities)
- [All errors at once](#all-errors-at-once)
- [Structured issues with paths](#structured-issues-with-paths)
- [discriminatedUnion](#discriminatedunion)
- [Quick Reference](#quick-reference)

## Breaking Changes
Expand Down Expand Up @@ -94,7 +95,7 @@ After:
const result = userDecoder.decode(badJson);
if (!result.isOk()) {
result.issues.forEach(issue => {
const location = issue.path.length > 0 ? issue.path.join('.') : 'root';
const location = issue.path.length > 0 ? JsonDecoder.formatIssuePath(issue.path) : 'root';
console.log(`${location}: ${issue.message}`);
});
}
Expand All @@ -104,7 +105,7 @@ if (!result.isOk()) {

In v3, `parse()` threw the raw error string and `decodePromise()` rejected with the raw error string. In v4, both throw or reject a real `Error`:

- `error.message` is the formatted issues string (each issue rendered as `path.join('.'): message`, joined by `; `).
- `error.message` is the formatted issues string (each issue rendered as `location: message`, joined by `; `). In the location, object keys are dot-joined and array indices use bracket notation (e.g. `roles[1]`). The same formatting is available standalone via `JsonDecoder.formatIssuePath(issue.path)`.
- `error.cause` is the structured `DecodingIssue[]`, so you can still inspect the issues programmatically.

Before:
Expand Down Expand Up @@ -162,29 +163,33 @@ const myStringDecoder = new Decoder<string>(json => (typeof json === 'string' ?

Decoder names are no longer part of error messages, and the failing key or index is now carried by the issue `path` instead of being embedded in the message text.

| Concern | v3 message | v4 message + path |
| ------------------ | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| Object field | `<User> decoder failed at key "id" with error: "x" is not a valid number` | `{ message: '"x" is not a valid number', path: ['id'] }` |
| Array index | `<User[]> decoder failed at index "1" with error: 2 is not a valid string` | `{ message: '2 is not a valid string', path: [1] }` |
| Record value | `<UserMap> record decoder failed at key "a" with error: "x" is not a valid number` | `{ message: '"x" is not a valid number', path: ['a'] }` |
| oneOf | `<Shape> decoder failed because true can't be decoded with any of the provided oneOf decoders` | the issues of the branch that decoded furthest (deepest `path`); see note below |
| Strict unknown key | `Unknown key "extra" found while processing strict <User> decoder` | `{ message: 'Unknown key "extra" found in strict object', path: [] }` |
| Primitive | `"x" is not a valid string` | `{ message: '"x" is not a valid string', path: [] }` (unchanged message) |
| Concern | v3 message | v4 message + path |
| ------------------ | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Object field | `<User> decoder failed at key "id" with error: "x" is not a valid number` | `{ message: '"x" is not a valid number', path: ['id'] }` |
| Array index | `<User[]> decoder failed at index "1" with error: 2 is not a valid string` | `{ message: '2 is not a valid string', path: [1] }` |
| Record value | `<UserMap> record decoder failed at key "a" with error: "x" is not a valid number` | `{ message: '"x" is not a valid number', path: ['a'] }` |
| oneOf | `<Shape> decoder failed because true can't be decoded with any of the provided oneOf decoders` | a "no alternative matched" summary plus every alternative's failure (same-path issues collapsed into one "X or Y" message); see note below |
| Strict unknown key | `Unknown key "extra" found while processing strict <User> decoder` | `{ message: 'Unknown key "extra" found in strict object', path: [] }` |
| Primitive | `"x" is not a valid string` | `{ message: '"x" is not a valid string', path: [] }` (unchanged message) |

`oneOf` deserves a special mention. In v3 it always produced one generic "can't be decoded with any of the provided oneOf decoders" message. In v4 it instead surfaces the issues from the branch that decoded furthest (the one whose failure reached the deepest `path`), because that is usually the most specific and actionable error:
`oneOf` deserves a special mention. In v3 it always produced one generic "can't be decoded with any of the provided oneOf decoders" message. In v4, when no branch matches, it returns a `no alternative matched (tried N)` summary followed by every alternative's failure. Issues that share a `path` are collapsed into a single "X or Y" message so competing alternatives don't read as conjunctive requirements:

```typescript
type Shape = { kind: 'circle'; radius: number } | null;

const shapeDecoder = JsonDecoder.oneOf<Shape>([JsonDecoder.object({ kind: JsonDecoder.literal('circle'), radius: JsonDecoder.number() }), JsonDecoder.null()]);

// The object branch fails deeper (at ['radius']) than the null branch (at the root),
// so the object branch's issues are surfaced.
shapeDecoder.decode({ kind: 'circle', radius: 'big' });
// Err({ issues: [{ message: '"big" is not a valid number', path: ['radius'] }] })
// Err({ issues: [
// { message: 'no alternative matched (tried 2)', path: [] },
// { message: '"big" is not a valid number', path: ['radius'] },
// { message: '{"kind":"circle","radius":"big"} is not null', path: [] }
// ] })
```

The old generic message survives only as a fallback when no branch produced any issue (for example, an empty `oneOf([])` decoder list): `{ message: '<value> could not be decoded with any of the provided decoders', path: [] }`.
An empty `oneOf([])` decoder list reports `{ message: 'no alternative matched (tried 0)', path: [] }`.

> **Tip:** for a union of objects that share a literal "tag" field, prefer the new [`discriminatedUnion`](#discriminatedunion) decoder — it validates only the matching variant and yields a precise, single-variant error instead of reporting every branch. See also the [Advanced Usage](advanced-usage.md) guide.

If you assert on error strings in your tests, switch to asserting on the structured `issues` array instead.

Expand Down Expand Up @@ -229,7 +234,7 @@ Because each issue exposes a `path` from the root of the decoded value to the fa
const result = userDecoder.decode({ id: 'bad', name: 42 });
if (!result.isOk()) {
const fieldErrors = result.issues.reduce<Record<string, string>>((acc, issue) => {
const field = issue.path.join('.');
const field = JsonDecoder.formatIssuePath(issue.path);
acc[field] = issue.message;
return acc;
}, {});
Expand All @@ -239,6 +244,27 @@ if (!result.isOk()) {

This structure also lines up with the [Standard Schema](https://standardschema.dev) issue shape, which `ts.data.json` implements out of the box.

### discriminatedUnion

v4 adds `discriminatedUnion` for tagged unions of objects that share a literal "tag" field. You give it the tag field name and a map from each tag value to its variant decoder; it reads the tag, validates only the matching variant, and reports a precise error when the tag is unknown — without the noise `oneOf` produces by trying every branch.

```typescript
const shapeDecoder = JsonDecoder.discriminatedUnion('type', {
circle: JsonDecoder.object({ type: JsonDecoder.literal('circle'), radius: JsonDecoder.number() }),
rectangle: JsonDecoder.object({ type: JsonDecoder.literal('rectangle'), width: JsonDecoder.number(), height: JsonDecoder.number() })
});

shapeDecoder.decode({ type: 'circle', radius: 5 }); // Ok({ value: { type: 'circle', radius: 5 } })

// Only the matching variant is checked:
shapeDecoder.decode({ type: 'circle', radius: 'big' });
// Err({ issues: [{ message: '"big" is not a valid number', path: ['radius'] }] })

// An unknown tag lists the expected values:
shapeDecoder.decode({ type: 'triangle' });
// Err({ issues: [{ message: '"type" must be one of "circle", "rectangle", but got "triangle"', path: ['type'] }] })
```

## Quick Reference

| | v3 | v4 |
Expand Down
7 changes: 4 additions & 3 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
<title>ts.data.json Documentation</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
Helvetica, Arial, sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica,
Arial, sans-serif;
max-width: 800px;
margin: 40px auto;
padding: 0 20px;
Expand Down Expand Up @@ -53,7 +54,7 @@
<h1>ts.data.json Documentation</h1>
<div class="latest-version">
<a href="latest/index.html" class="version-link"
>Latest Version (v4.0.0)</a
>Latest Version (v4.1.0)</a
>
</div>
<div class="older-versions">
Expand Down
2 changes: 1 addition & 1 deletion docs/latest/assets/navigation.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading