ParsersCommunity

Zod codecs

Using Zod codecs for (de)serialisation in custom nuqs parser

Since zod@^4.1, you can use codecs to add bidirectional serialisation / deserialisation to your validation schemas:

import { z } from 'zod'

// Similar to parseAsTimestamp in nuqs:
const dateTimestampCodec = z.codec(z.string().regex(/^\d+$/), z.date(), {
  decode: (query) => new Date(parseInt(query)),
  encode: (date) => date.valueOf().toFixed()
})

Demo

Zod Codecs Demo
This demo shows how Zod codecs can transform complex data structures into URL-safe strings using base64url encoding and JSON serialization.
Loading demo…

Source code:

import { createParser } from 'nuqs/server'
import { z } from 'zod'

function createZodCodecParser<
  Input extends z.ZodCoercedString<string> | z.ZodPipe<any, any>,
  Output extends z.ZodType
>(
  codec: z.ZodCodec<Input, Output> | z.ZodPipe<Input, Output>,
  eq: (a: z.output<Output>, b: z.output<Output>) => boolean = (a, b) => a === b
) {
  return createParser<z.output<Output>>({
    parse(query) {
      return codec.parse(query)
    },
    serialize(value) {
      return codec.encode(value)
    },
    eq
  })
}

// --

// All parsers from the Zod docs:
const jsonCodec = <T extends z.core.$ZodType>(schema: T) =>
  z.codec(z.string(), schema, {
    decode: (jsonString, ctx) => {
      try {
        return JSON.parse(jsonString)
      } catch (err: any) {
        ctx.issues.push({
          code: 'invalid_format',
          format: 'json',
          input: jsonString,
          message: err.message
        })
        return z.NEVER
      }
    },
    encode: value => JSON.stringify(value)
  })

const base64urlToBytes = z.codec(z.base64url(), z.instanceof(Uint8Array), {
  decode: base64urlString => z.util.base64urlToUint8Array(base64urlString),
  encode: bytes => z.util.uint8ArrayToBase64url(bytes)
})

const utf8ToBytes = z.codec(z.string(), z.instanceof(Uint8Array), {
  decode: str => new TextEncoder().encode(str),
  encode: bytes => new TextDecoder().decode(bytes)
})
const bytesToUtf8 = invertCodec(utf8ToBytes)

// --

function invertCodec<A extends z.ZodType, B extends z.ZodType>(
  codec: z.ZodCodec<A, B>
): z.ZodCodec<B, A> {
  return z.codec<B, A>(codec.out, codec.in, {
    decode(value, ctx) {
      try {
        return codec.encode(value)
      } catch (err) {
        ctx.issues.push({
          code: 'invalid_format',
          format: 'invert.decode',
          input: String(value),
          message: err instanceof z.ZodError ? err.message : String(err)
        })
        return z.NEVER
      }
    },
    encode(value, ctx) {
      try {
        return codec.decode(value)
      } catch (err) {
        ctx.issues.push({
          code: 'invalid_format',
          format: 'invert.encode',
          input: String(value),
          message: err instanceof z.ZodError ? err.message : String(err)
        })
        return z.NEVER
      }
    }
  })
}

// --

const userSchema = z.object({
  name: z.string(),
  age: z.number()
})

// Composition always wins.
const codec = base64urlToBytes.pipe(bytesToUtf8).pipe(jsonCodec(userSchema))

export const userJsonBase64Parser = createZodCodecParser(
  codec,
  (a, b) => a === b || (a.name === b.name && a.age === b.age)
)

Refinements

The cool part is being able to add string constraints to the first type in a codec. It has to be rooted as a string data type (because that’s what the URL will give us), but you can add refinements:

z.codec(z.uuid(), ...)
z.codec(z.email(), ...)
z.codec(z.base64url(), ...)

See the complete list of string-based refinements you can use.

Caveats

As stated in the Zod docs, you cannot use transforms in codecs.

On this page