Skip to content

Commit 0102387

Browse files
ushironokoclaude
andcommitted
docs(examples): add advanced examples (union, recursive JSON, explicit Infer)
The basic example only covers an object of primitives. Add an `examples/advanced` package showing the three patterns that need extra notation, each materialized into a concrete `.gen.ts` type: - a discriminated union that narrows on a `kind` literal; - a recursive/cyclic JSON value, which needs a hand-written self-referential type, a `GenericSchema<…>` annotation to break the inference cycle, and `lazy(() => …)` at each self-reference (without the annotation the recursive position silently degrades to `any`); - the explicit `export type T = Infer<typeof schema>` marker for a helper-built schema that syntactic auto-discovery cannot find. `main.ts` consumes the generated types and pins `null` in the JSON output via typed assignments, so codegen dropping a member would fail the type-check. Link the new package from the root and basic READMEs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1cc60d6 commit 0102387

13 files changed

Lines changed: 300 additions & 0 deletions

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,11 @@ a documented mapping (and warns on anything it cannot represent).
162162
- **`union`** emits a single schema-level issue on failure (no per-member aggregation yet).
163163
- **`pipe(schema, transform(fn))`** infers `fn`'s input from an explicit parameter annotation; annotate it (`transform((s: string) => …)`) when the input isn't otherwise constrained.
164164
165+
## Examples
166+
167+
- [`examples/basic`](examples/basic) — the smallest end-to-end loop: schema → generated type → validate.
168+
- [`examples/advanced`](examples/advanced) — discriminated unions, recursive (cyclic) schemas (`lazy` + `GenericSchema`), and the explicit `export type T = Infer<typeof schema>` marker.
169+
165170
## Development
166171
167172
```bash

bun.lock

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/advanced/README.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# @tskm/example-advanced
2+
3+
Three patterns past the [basic loop](../basic): **discriminated unions, recursive (cyclic)
4+
schemas, and the explicit `Infer` marker.** Each schema is materialized into a concrete
5+
`.gen.ts` type by `tskm gen`, so consuming it ([`src/main.ts`](src/main.ts)) costs the type
6+
system nothing.
7+
8+
## 1. Discriminated union
9+
10+
[`src/union.schema.ts`](src/union.schema.ts) — every member carries a `kind` literal:
11+
12+
```ts
13+
export const shapeSchema = union([
14+
object({ kind: literal("circle"), radius: number() }),
15+
object({ kind: literal("rectangle"), width: number(), height: number() }),
16+
object({ kind: literal("text"), content: string() }),
17+
])
18+
```
19+
20+
generates the full union as a concrete type ([`union.schema.gen.ts`](src/union.schema.gen.ts)),
21+
which narrows on `kind` like any hand-written union:
22+
23+
```ts
24+
export type Shape = {
25+
kind: "circle";
26+
radius: number;
27+
} | {
28+
kind: "rectangle";
29+
width: number;
30+
height: number;
31+
} | {
32+
kind: "text";
33+
content: string;
34+
}
35+
```
36+
37+
## 2. Recursive / cyclic schema — the special notation
38+
39+
[`src/json.schema.ts`](src/json.schema.ts) models a JSON value, which is both a **union** and
40+
**recursive** (a JSON value contains JSON values). TypeScript cannot *infer* a self-referential
41+
type, so a recursive schema needs three things that a normal schema does not:
42+
43+
1. **Hand-write the recursive type** — `Json` mentions `Json`.
44+
2. **Annotate the const** with `GenericSchema<Json>` — this breaks the inference cycle.
45+
3. **Wrap each self-reference in `lazy(() => …)`** — so the schema object can be built before
46+
it finishes referring to itself (the getter runs on first parse).
47+
48+
```ts
49+
export type Json = string | number | boolean | null | Json[] | { [key: string]: Json }
50+
51+
export const jsonSchema: GenericSchema<Json> = union([
52+
string(),
53+
number(),
54+
boolean(),
55+
null_(),
56+
array(lazy(() => jsonSchema)),
57+
record(lazy(() => jsonSchema)),
58+
])
59+
```
60+
61+
`tskm gen` materializes a correct **self-referential** type ([`json.schema.gen.ts`](src/json.schema.gen.ts)):
62+
63+
```ts
64+
export type Json = string | number | boolean | Json[] | {
65+
[x: string]: Json;
66+
} | null
67+
```
68+
69+
> **The annotation is required, not optional.** Without `GenericSchema<Json>` the recursive
70+
> position silently degrades to `any` — tskm's fail-closed guard only inspects the top-level
71+
> type, not nested `any`, so codegen would emit a wrong type with no diagnostic. (At runtime
72+
> `lazy` follows the input's depth and is not cycle-guarded, so a pathologically deep value can
73+
> overflow the stack.)
74+
75+
## 3. The explicit `Infer` marker — opt-in discovery
76+
77+
tskm's auto-discovery is syntactic: it only finds a **direct** `export const x = object(…)`
78+
(or another tskm factory). A schema built by a **helper** is invisible to it. In
79+
[`src/book.schema.ts`](src/book.schema.ts) the const's initializer is a call to `makeEntity`,
80+
not a tskm factory:
81+
82+
```ts
83+
function makeEntity<E extends ObjectEntries>(entries: E) {
84+
return object({ id: string(), ...entries })
85+
}
86+
87+
export const bookSchema = makeEntity({ title: string(), pages: number() })
88+
89+
// Opt in explicitly — this marker is what `tskm gen` keys on.
90+
export type Book = Infer<typeof bookSchema>
91+
```
92+
93+
`export type T = Infer<typeof schema>` (or `InferOutput<…>`) tells the compiler to materialize
94+
that schema anyway. It writes the concrete `Book` into [`book.schema.gen.ts`](src/book.schema.gen.ts);
95+
import `Book` from there. (`tskm gen --mode inplace` rewrites the marker *in place* instead of
96+
writing a sidecar — see the root README.)
97+
98+
## Generate
99+
100+
From the repository root (after `bun install` + `bun run build`):
101+
102+
```bash
103+
node packages/compiler/dist/cli.mjs gen --root examples/advanced
104+
# in a published project this is simply: npx tskm gen (or bunx tskm gen)
105+
```
106+
107+
`tsconfig.json` maps `@tskm/core` to the workspace source via `paths` so the checker can resolve
108+
the inferred output type. In a real project `@tskm/core` is a normal dependency and no `paths`
109+
entry is needed.

examples/advanced/package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "@tskm/example-advanced",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"dependencies": {
7+
"@tskm/core": "workspace:*"
8+
},
9+
"devDependencies": {
10+
"@tskm/compiler": "workspace:*"
11+
}
12+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// AUTO-GENERATED by tskm. Do not edit.
2+
3+
export type Book = {
4+
id: string;
5+
title: string;
6+
pages: number;
7+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { type Infer, number, type ObjectEntries, object, string } from "@tskm/core"
2+
3+
// tskm's auto-discovery is syntactic and conservative: it only matches a *direct*
4+
// `export const x = object(…)` (or another tskm factory). A schema returned by a HELPER is
5+
// invisible to it — the initializer here is a call to `makeEntity`, not to a tskm factory.
6+
function makeEntity<E extends ObjectEntries>(entries: E) {
7+
return object({ id: string(), ...entries })
8+
}
9+
10+
export const bookSchema = makeEntity({
11+
title: string(),
12+
pages: number(),
13+
})
14+
15+
// So you opt in explicitly. `export type … = Infer<typeof schema>` is the marker `tskm gen`
16+
// keys on; it materializes the concrete `Book` type into book.schema.gen.ts. Import `Book`
17+
// from the generated file (main.ts) — this alias stays the generic, type-level form and
18+
// exists purely to tell the compiler "also generate this one".
19+
export type Book = Infer<typeof bookSchema>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// AUTO-GENERATED by tskm. Do not edit.
2+
3+
export type Json = string | number | boolean | Json[] | {
4+
[x: string]: Json;
5+
} | null
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import {
2+
array,
3+
boolean,
4+
type GenericSchema,
5+
lazy,
6+
null_,
7+
number,
8+
record,
9+
string,
10+
union,
11+
} from "@tskm/core"
12+
13+
// A recursive (self-referential / cyclic) schema. TypeScript cannot INFER a type that
14+
// refers to itself, so a recursive schema needs three special things:
15+
//
16+
// 1. Hand-write the recursive type — `Json` mentions `Json`.
17+
// 2. Annotate the const with `GenericSchema<Json>` — this breaks the inference cycle so
18+
// the schema type-checks against the hand-written shape.
19+
// 3. Wrap each self-reference in `lazy(() => jsonSchema)` so the schema object can be
20+
// constructed before it finishes referring to itself (the getter runs on first parse).
21+
//
22+
// The annotation is REQUIRED, not optional: without it the recursive position silently
23+
// degrades to `any` (tskm's fail-closed guard only inspects the top-level type, not nested
24+
// `any`), so codegen would quietly emit a wrong type with no diagnostic.
25+
export type Json = string | number | boolean | null | Json[] | { [key: string]: Json }
26+
27+
export const jsonSchema: GenericSchema<Json> = union([
28+
string(),
29+
number(),
30+
boolean(),
31+
null_(),
32+
array(lazy(() => jsonSchema)),
33+
record(lazy(() => jsonSchema)),
34+
])

examples/advanced/src/main.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { parse, safeParse } from "@tskm/core"
2+
import type { Book } from "./book.schema.gen.ts"
3+
import { bookSchema } from "./book.schema.ts"
4+
import type { Json } from "./json.schema.gen.ts"
5+
import { jsonSchema } from "./json.schema.ts"
6+
import type { Shape } from "./union.schema.gen.ts"
7+
import { shapeSchema } from "./union.schema.ts"
8+
9+
// 1. Discriminated union — the generated `Shape` narrows on `kind`, with no
10+
// `Infer<typeof shapeSchema>` paid at the use site.
11+
const shapes: Shape[] = [
12+
{ kind: "circle", radius: 2 },
13+
{ kind: "rectangle", width: 3, height: 4 },
14+
{ kind: "text", content: "hello" },
15+
]
16+
for (const shape of shapes) {
17+
switch (shape.kind) {
18+
case "circle":
19+
console.log("circle area:", Math.PI * shape.radius ** 2)
20+
break
21+
case "rectangle":
22+
console.log("rect area:", shape.width * shape.height)
23+
break
24+
case "text":
25+
console.log("text:", shape.content)
26+
break
27+
}
28+
}
29+
const parsedShape = parse(shapeSchema, { kind: "circle", radius: 1 })
30+
console.log("parsed shape kind:", parsedShape.kind)
31+
32+
// 2. Recursive JSON value — the generated `Json` is self-referential AND keeps `null`.
33+
// These assignments force every member to exist: if codegen ever dropped `null`,
34+
// arrays, or the record case, `tsgo --noEmit` over this file would fail to compile.
35+
const jsonNull: Json = null
36+
const jsonArray: Json = [1, "two", true, null]
37+
const jsonObject: Json = { a: 1, b: [null], c: { nested: "deep" } }
38+
console.log("json values:", jsonNull, jsonArray, jsonObject)
39+
40+
const parsedJson = parse(jsonSchema, { items: [1, null, "x"], ok: true })
41+
console.log("parsed json:", JSON.stringify(parsedJson))
42+
43+
// A function is not valid JSON — the recursive union rejects it.
44+
const notJson = safeParse(jsonSchema, () => 1)
45+
console.log("function is valid JSON?", notJson.success)
46+
47+
// 3. Explicit `Infer` marker — `bookSchema` is built by a helper, so auto-discovery
48+
// skips it; the `export type Book = Infer<…>` marker is what made `tskm gen` emit
49+
// the concrete `Book` type that we import here.
50+
const book: Book = { id: "b1", title: "Types", pages: 200 }
51+
const bookResult = safeParse(bookSchema, book)
52+
if (bookResult.success) {
53+
console.log("book ok:", bookResult.output.title)
54+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// AUTO-GENERATED by tskm. Do not edit.
2+
3+
export type Shape = {
4+
kind: "circle";
5+
radius: number;
6+
} | {
7+
kind: "rectangle";
8+
width: number;
9+
height: number;
10+
} | {
11+
kind: "text";
12+
content: string;
13+
}

0 commit comments

Comments
 (0)