Skip to content

Commit c8fb26c

Browse files
docs: recursive types
1 parent 590c8da commit c8fb26c

4 files changed

Lines changed: 235 additions & 0 deletions

File tree

packages/pure-parse/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export default defineConfig({
9090
},
9191
{ text: 'Transformations', link: '/guide/transformations' },
9292
{ text: 'Custom Parsers', link: '/guide/customizing' },
93+
{ text: 'Recursion', link: '/guide/recursion' },
9394
{ text: 'Formatting', link: '/guide/formatting' },
9495
{ text: 'JSON', link: '/guide/json' },
9596
{ text: 'Performance', link: '/guide/performance' },
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Recursion
2+
3+
To define a self-referential validation function, use the [lazy](../api/common/lazy.md) function. This is useful for validating recursive data structures like trees.
4+
5+
For example:
6+
7+
::: code-group
8+
9+
```ts [Parser]
10+
import { lazy, type Parser, object, parseString, optional } from 'pure-parse'
11+
12+
type Person = {
13+
name: string
14+
father?: Person
15+
mother?: Person
16+
}
17+
18+
const parsePerson: Parser<Person> = lazy(() =>
19+
object({
20+
name: parseString,
21+
father: optional(parsePerson),
22+
mother: optional(parsePerson),
23+
}),
24+
)
25+
```
26+
27+
```ts [Guard]
28+
import {
29+
lazy,
30+
type Guard,
31+
objectGuard,
32+
parseString,
33+
optionalGuard,
34+
} from 'pure-parse'
35+
36+
type Person = {
37+
name: string
38+
father?: Person
39+
mother?: Person
40+
}
41+
42+
const isPerson: Guard<Person> = lazy(() =>
43+
objectGuard({
44+
name: parseString,
45+
father: optionalGuard(parsePerson),
46+
mother: optionalGuard(parsePerson),
47+
}),
48+
)
49+
```
50+
51+
:::
52+
53+
> [!NOTE]
54+
> You must use explicit type annotations for recursive types. Type inferrence is not available in this case.
55+
56+
There are two tricks to note here:
57+
58+
1. The `lazy` function ensures that the code works at runtime. Without `lazy`, `parsePerson` would not be defined on the `father` and `mother` properties, leading to a runtime error.
59+
> TS2448: Block-scoped variable \* used before its declaration.
60+
```ts
61+
const parsePerson: Parser<Person> = object({
62+
name: parseString,
63+
// TS2448: Block-scoped variable parsePerson used before its declaration.
64+
father: optional(parsePerson),
65+
// TS2448: Block-scoped variable parsePerson used before its declaration.
66+
mother: optional(parsePerson),
67+
})
68+
```
69+
2. The explicit type annotation is due to TypeScript's inability to infer cyclical types:
70+
> TS7022: \* implicitly has type any because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
71+
```ts
72+
// TS7022 parsePerson implicitly has type any because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
73+
const parsePerson = lazy(() =>
74+
object({
75+
name: parseString,
76+
father: optional(parsePerson),
77+
mother: optional(parsePerson),
78+
}),
79+
)
80+
```

packages/pure-parse/src/common/lazy.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it, test, vi } from 'vitest'
22
import { lazy } from './lazy'
33
import { Equals } from '../internals'
4+
import { object, optional, Parser, parseString, success } from '../parsers'
45

56
describe('lazy', () => {
67
it('only constructs the function once (memoization)', () => {
@@ -26,4 +27,132 @@ describe('lazy', () => {
2627
const fn3 = lazy(() => (base: number, exp: number) => base ** exp)
2728
const t3: Equals<typeof fn3, (base: number, exp: number) => number> = true
2829
})
30+
it('supports direct recursive parsers', () => {
31+
type Person = {
32+
name: string
33+
mother?: Person
34+
father?: Person
35+
}
36+
const parsePerson: Parser<Person> = lazy(() =>
37+
object({
38+
name: parseString,
39+
mother: optional(parsePerson),
40+
father: optional(parsePerson),
41+
}),
42+
)
43+
44+
// Test that it works in runtime, even though it is the types that matter
45+
const person1: Person = {
46+
name: 'Johannes',
47+
}
48+
expect(parsePerson(person1)).toEqual(success(person1))
49+
50+
const person2: Person = {
51+
name: 'Bob',
52+
mother: {
53+
name: 'Alice',
54+
},
55+
father: {
56+
name: 'Evan',
57+
},
58+
}
59+
expect(parsePerson(person2)).toEqual(success(person2))
60+
61+
const person3: Person = {
62+
name: 'Charlie',
63+
mother: {
64+
name: 'Diana',
65+
mother: {
66+
name: 'Diana',
67+
},
68+
father: {
69+
name: 'Evan',
70+
},
71+
},
72+
father: {
73+
name: 'Johan',
74+
mother: {
75+
name: 'Alice',
76+
},
77+
father: {
78+
name: 'Frank',
79+
},
80+
},
81+
}
82+
expect(parsePerson(person3)).toEqual(success(person3))
83+
84+
// Failure cases
85+
const person4 = {
86+
name: 'A',
87+
mother: {
88+
name: 'B',
89+
father: {}, // Missing required field 'name'
90+
},
91+
}
92+
expect(parsePerson(person4)).toEqual(
93+
expect.objectContaining({
94+
tag: 'failure',
95+
}),
96+
)
97+
})
98+
it('supports indirect recursive parsers', () => {
99+
type Family = {
100+
mother: Person
101+
father: Person
102+
}
103+
type Person = {
104+
family?: Family
105+
}
106+
const parsePerson: Parser<Person> = lazy(() =>
107+
object({
108+
family: optional(parseFamily),
109+
}),
110+
)
111+
const parseFamily: Parser<Family> = lazy(() =>
112+
object({
113+
mother: parsePerson,
114+
father: parsePerson,
115+
}),
116+
)
117+
118+
// Test that it works in runtime, even though it is the types that matter
119+
const person1: Person = {}
120+
expect(parsePerson(person1)).toEqual(success(person1))
121+
122+
const person2: Person = {
123+
family: {
124+
mother: {},
125+
father: {},
126+
},
127+
}
128+
expect(parsePerson(person2)).toEqual(success(person2))
129+
130+
const person3: Person = {
131+
family: {
132+
mother: {
133+
family: {
134+
mother: {},
135+
father: {},
136+
},
137+
},
138+
father: {
139+
family: {
140+
mother: {},
141+
father: {},
142+
},
143+
},
144+
},
145+
}
146+
expect(parsePerson(person3)).toEqual(success(person3))
147+
148+
// Failure cases
149+
const person4 = {
150+
family: {},
151+
}
152+
expect(parsePerson(person4)).toEqual(
153+
expect.objectContaining({
154+
tag: 'failure',
155+
}),
156+
)
157+
})
29158
})

packages/pure-parse/src/common/lazy.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,28 @@
1+
/**
2+
* Creates a lazy-loaded function that initializes the function only when it is called for the first time.
3+
* With `lazy`, you can create recursive parsers without running into circular dependencies.
4+
* Also useful to lazily initialize just-in-time compuliled parsers and guards.
5+
* @example
6+
* Create recursive parsers with `lazy`.
7+
* Note that you must use explicit type annotations:
8+
* ```ts
9+
* import { lazy, type Parser, object, parseString, optional } from 'pure-parse'
10+
*
11+
* type Person = {
12+
* name: string
13+
* father?: Person
14+
* mother?: Person
15+
* }
16+
* const parsePerson: Parser<Person> = lazy(() =>
17+
* object({
18+
* name: parseString,
19+
* father: optional(parsePerson),
20+
* mother: optional(parsePerson),
21+
* }),
22+
* )
23+
* ```
24+
* @param constructFn
25+
*/
126
export const lazy = <T extends (...args: never[]) => unknown>(
227
constructFn: () => T,
328
): T => {

0 commit comments

Comments
 (0)