Skip to content

Commit af28c8a

Browse files
authored
doc: Add Zod codecs community parsers (#1097)
1 parent 77dc7c3 commit af28c8a

File tree

12 files changed

+451
-91
lines changed

12 files changed

+451
-91
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"title": "Community",
3-
"pages": ["tanstack-table", "effect-schema"]
3+
"pages": ["tanstack-table", "effect-schema", "zod-codecs"]
44
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
'use client'
2+
3+
import { CodeBlock } from '@/src/components/code-block.client'
4+
import { QuerySpy } from '@/src/components/query-spy'
5+
import { Button } from '@/src/components/ui/button'
6+
import { Input } from '@/src/components/ui/input'
7+
import { Label } from '@/src/components/ui/label'
8+
import { Dices } from 'lucide-react'
9+
import { useQueryState } from 'nuqs'
10+
import { userJsonBase64Parser } from './zod-codecs.lib'
11+
import { ZodCodecsDemoSkeleton } from './zod-codecs.skeleton'
12+
13+
export function ZodCodecsDemo() {
14+
const [user, setUser] = useQueryState(
15+
'user',
16+
userJsonBase64Parser.withDefault({
17+
name: 'John Doe',
18+
age: 42
19+
})
20+
)
21+
22+
const handleReset = () => {
23+
setUser({
24+
name: 'John Doe',
25+
age: 42
26+
})
27+
}
28+
29+
const handleRandomize = () => {
30+
const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank']
31+
const randomName = names[Math.floor(Math.random() * names.length)]
32+
const randomAge = Math.floor(Math.random() * 50) + 18
33+
setUser({
34+
name: randomName,
35+
age: randomAge
36+
})
37+
}
38+
39+
return (
40+
<ZodCodecsDemoSkeleton>
41+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
42+
<div className="space-y-2">
43+
<Label htmlFor="user-name">Name</Label>
44+
<Input
45+
id="user-name"
46+
value={user.name}
47+
placeholder="Enter your name..."
48+
onChange={e => setUser(old => ({ ...old, name: e.target.value }))}
49+
/>
50+
</div>
51+
<div className="space-y-2">
52+
<Label htmlFor="user-age">Age</Label>
53+
<Input
54+
id="user-age"
55+
type="number"
56+
min="1"
57+
max="120"
58+
value={user.age}
59+
onChange={e =>
60+
setUser(old => ({
61+
...old,
62+
age: Number(e.target.valueAsNumber) || 0
63+
}))
64+
}
65+
/>
66+
</div>
67+
</div>
68+
69+
<div className="flex gap-2">
70+
<Button onClick={handleRandomize}>
71+
<Dices size={18} className="mr-2 inline-block" role="presentation" />
72+
Randomize
73+
</Button>
74+
<Button onClick={handleReset} variant="outline">
75+
Clear
76+
</Button>
77+
</div>
78+
79+
<div className="space-y-4">
80+
<div>
81+
<div className="mb-2 flex items-center gap-2">
82+
<Label className="text-sm font-medium">Encoded in the URL:</Label>
83+
</div>
84+
<QuerySpy keepKeys={['user']} />
85+
</div>
86+
<div>
87+
<Label className="text-sm font-medium">Current Data:</Label>
88+
<CodeBlock
89+
code={JSON.stringify(user, null, 2)}
90+
lang="json"
91+
className="mt-2"
92+
allowCopy={false}
93+
/>
94+
</div>
95+
<div className="not-prose">
96+
<strong className="mt-4 mb-2">How it works</strong>
97+
<p className="text-muted-foreground mt-2 mb-2 text-sm">
98+
On write (updating the URL):
99+
</p>
100+
<p>
101+
<ol className="text-muted-foreground ml-2 list-inside list-decimal space-y-1 text-sm">
102+
<li>User object is JSON stringified</li>
103+
<li>JSON string is encoded as UTF-8 bytes</li>
104+
<li>Bytes are encoded as base64url string</li>
105+
<li>Result is stored in the URL query parameter</li>
106+
</ol>
107+
<p className="text-muted-foreground mt-2 text-sm">
108+
On read, the process is reversed to decode the URL string back
109+
into the original object.
110+
</p>
111+
</p>
112+
</div>
113+
</div>
114+
</ZodCodecsDemoSkeleton>
115+
)
116+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { createParser } from 'nuqs/server'
2+
import { z } from 'zod'
3+
4+
function createZodCodecParser<
5+
Input extends z.ZodCoercedString<string> | z.ZodPipe<any, any>,
6+
Output extends z.ZodType
7+
>(
8+
codec: z.ZodCodec<Input, Output> | z.ZodPipe<Input, Output>,
9+
eq: (a: z.output<Output>, b: z.output<Output>) => boolean = (a, b) => a === b
10+
) {
11+
return createParser<z.output<Output>>({
12+
parse(query) {
13+
return codec.parse(query)
14+
},
15+
serialize(value) {
16+
return codec.encode(value)
17+
},
18+
eq
19+
})
20+
}
21+
22+
// --
23+
24+
// All parsers from the Zod docs:
25+
const jsonCodec = <T extends z.core.$ZodType>(schema: T) =>
26+
z.codec(z.string(), schema, {
27+
decode: (jsonString, ctx) => {
28+
try {
29+
return JSON.parse(jsonString)
30+
} catch (err: any) {
31+
ctx.issues.push({
32+
code: 'invalid_format',
33+
format: 'json',
34+
input: jsonString,
35+
message: err.message
36+
})
37+
return z.NEVER
38+
}
39+
},
40+
encode: value => JSON.stringify(value)
41+
})
42+
43+
const base64urlToBytes = z.codec(z.base64url(), z.instanceof(Uint8Array), {
44+
decode: base64urlString => z.util.base64urlToUint8Array(base64urlString),
45+
encode: bytes => z.util.uint8ArrayToBase64url(bytes)
46+
})
47+
48+
const utf8ToBytes = z.codec(z.string(), z.instanceof(Uint8Array), {
49+
decode: str => new TextEncoder().encode(str),
50+
encode: bytes => new TextDecoder().decode(bytes)
51+
})
52+
const bytesToUtf8 = invertCodec(utf8ToBytes)
53+
54+
// --
55+
56+
function invertCodec<A extends z.ZodType, B extends z.ZodType>(
57+
codec: z.ZodCodec<A, B>
58+
): z.ZodCodec<B, A> {
59+
return z.codec<B, A>(codec.out, codec.in, {
60+
decode(value, ctx) {
61+
try {
62+
return codec.encode(value)
63+
} catch (err) {
64+
ctx.issues.push({
65+
code: 'invalid_format',
66+
format: 'invert.decode',
67+
input: String(value),
68+
message: err instanceof z.ZodError ? err.message : String(err)
69+
})
70+
return z.NEVER
71+
}
72+
},
73+
encode(value, ctx) {
74+
try {
75+
return codec.decode(value)
76+
} catch (err) {
77+
ctx.issues.push({
78+
code: 'invalid_format',
79+
format: 'invert.encode',
80+
input: String(value),
81+
message: err instanceof z.ZodError ? err.message : String(err)
82+
})
83+
return z.NEVER
84+
}
85+
}
86+
})
87+
}
88+
89+
// --
90+
91+
const userSchema = z.object({
92+
name: z.string(),
93+
age: z.number()
94+
})
95+
96+
// Composition always wins.
97+
const codec = base64urlToBytes.pipe(bytesToUtf8).pipe(jsonCodec(userSchema))
98+
99+
export const userJsonBase64Parser = createZodCodecParser(
100+
codec,
101+
(a, b) => a === b || (a.name === b.name && a.age === b.age)
102+
)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
title: Zod codecs
3+
description: Using Zod codecs for (de)serialisation in custom nuqs parser
4+
---
5+
6+
Since `zod@^4.1`, you can use [codecs](https://zod.dev/codecs)
7+
to add bidirectional serialisation / deserialisation to your validation schemas:
8+
9+
```ts
10+
import { z } from 'zod'
11+
12+
// Similar to parseAsTimestamp in nuqs:
13+
const dateTimestampCodec = z.codec(z.string().regex(/^\d+$/), z.date(), {
14+
decode: (query) => new Date(parseInt(query)),
15+
encode: (date) => date.valueOf().toFixed()
16+
})
17+
```
18+
19+
## Demo
20+
21+
<iframe
22+
src="https://www.youtube-nocookie.com/embed/k4lWvklUxUE"
23+
title="YouTube video player"
24+
frameBorder="0"
25+
allow="autoplay; encrypted-media; picture-in-picture; web-share"
26+
referrerPolicy="strict-origin-when-cross-origin"
27+
allowFullScreen
28+
className='aspect-video w-full max-w-2xl mx-auto mb-8'
29+
/>
30+
31+
import { ZodCodecsDemo } from './zod-codecs.demo'
32+
import { ZodCodecsDemoSkeleton } from './zod-codecs.skeleton'
33+
import { Suspense } from 'react'
34+
35+
<Suspense fallback={(
36+
<ZodCodecsDemoSkeleton className='animate-pulse'>
37+
<div className='h-32 bg-muted/25 rounded-md flex items-center justify-center text-sm text-muted-foreground'>
38+
Loading demo…
39+
</div>
40+
</ZodCodecsDemoSkeleton>
41+
)}>
42+
<ZodCodecsDemo/>
43+
</Suspense>
44+
45+
import { ZodCodecsSource } from './zod-codecs.source'
46+
47+
Source code:
48+
49+
<ZodCodecsSource/>
50+
51+
## Refinements
52+
53+
The cool part is being able to add string constraints to the first type in a codec.
54+
It has to be rooted as a string data type (because that's what the URL
55+
will give us), but you can add **refinements**:
56+
57+
```ts
58+
z.codec(z.uuid(), ...)
59+
z.codec(z.email(), ...)
60+
z.codec(z.base64url(), ...)
61+
```
62+
63+
See the [complete list](https://zod.dev/api?id=string-formats) of string-based
64+
refinements you can use.
65+
66+
<Callout title="Caveats" type="warning">
67+
As stated in the Zod docs, you [cannot use transforms in codecs](https://zod.dev/codecs#transforms).
68+
</Callout>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import {
2+
Card,
3+
CardContent,
4+
CardDescription,
5+
CardHeader,
6+
CardTitle
7+
} from '@/src/components/ui/card'
8+
import { cn } from '@/src/lib/utils'
9+
import type { ComponentProps } from 'react'
10+
11+
export function ZodCodecsDemoSkeleton({
12+
children,
13+
className,
14+
...props
15+
}: ComponentProps<'div'>) {
16+
return (
17+
<Card className={cn('border-dashed py-4', className)} {...props}>
18+
<CardHeader className="px-4">
19+
<CardTitle className="text-xl">Zod Codecs Demo</CardTitle>
20+
<CardDescription>
21+
This demo shows how Zod codecs can transform complex data structures
22+
into URL-safe strings using base64url encoding and JSON serialization.
23+
</CardDescription>
24+
</CardHeader>
25+
<CardContent className="space-y-6 px-4">{children}</CardContent>
26+
</Card>
27+
)
28+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { CodeBlock } from '@/src/components/code-block'
2+
import { readFile } from 'node:fs/promises'
3+
4+
export async function ZodCodecsSource() {
5+
const filePath =
6+
process.cwd() + '/content/docs/parsers/community/zod-codecs.lib.ts'
7+
const source = await readFile(filePath, 'utf8')
8+
return <CodeBlock code={source.trim()} />
9+
}

packages/docs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
"tailwind-merge": "^3.3.1",
6262
"tailwindcss": "^4.1.12",
6363
"tailwindcss-animate": "^1.0.7",
64-
"zod": "^4.0.17"
64+
"zod": "^4.1.5"
6565
},
6666
"devDependencies": {
6767
"@shikijs/transformers": "^3.11.0",

packages/docs/src/app/(pages)/stats/lib/npm.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import dayjs from 'dayjs'
22
import isoWeek from 'dayjs/plugin/isoWeek'
3+
import minMax from 'dayjs/plugin/minMax'
34
import 'server-only'
45
import { z } from 'zod'
56

67
dayjs.extend(isoWeek)
8+
dayjs.extend(minMax)
79

810
export type Datum = {
911
date: string
@@ -60,11 +62,24 @@ async function getLastNDays(pkg: string, n: number): Promise<Datum[]> {
6062
}
6163
}
6264

65+
async function getPackageCreationDate(pkg: string): Promise<dayjs.Dayjs> {
66+
const npmStatsEpoch = dayjs('2015-01-10')
67+
try {
68+
const res = await get<{ time: Record<string, string> }>(
69+
`https://registry.npmjs.org/${pkg}`
70+
)
71+
return dayjs.max(npmStatsEpoch, dayjs(res.time.created))
72+
} catch (e) {
73+
console.error(e)
74+
return npmStatsEpoch
75+
}
76+
}
77+
6378
async function getAllTime(pkg: string): Promise<number> {
6479
let downloads: number = 0
65-
const now = dayjs()
66-
let start = dayjs('2015-01-10') // NPM stats epoch
80+
let start = dayjs(await getPackageCreationDate(pkg))
6781
let end = start.add(18, 'month')
82+
const now = dayjs()
6883
while (start.isBefore(now)) {
6984
const url = `https://api.npmjs.org/downloads/range/${start.format(
7085
'YYYY-MM-DD'

0 commit comments

Comments
 (0)