Using Arktype in Place of Zod - How to Adapt Parsers

Gustavo Guichard (Guga) - Jun 12 - - Dev Community

Ever since I started using Zod, a TypeScript-first schema declaration and validation library, I've been a big fan and started using it in all my projects. Zod allows you to ensure the safety of your data at runtime, extending TypeScript’s type-checking capabilities beyond compile-time. Whenever I need to validate data from an outside source, such as an API, FormData, or URL, Zod has been my go-to tool.

I created, co-created, and worked on entire OSS libraries that are based on the principle of having strong type checking at both type and runtime levels.

A newfound love

Arktype has been on my radar for a while now, it offers similar validation capabilities but with some unique features that caught my eye, like the way it lets you define validators using the same syntax you use to define types.

I finally got the chance to use it in a project, and it was delightful.

The deal is that I'm using those Zod based libraries in the project, and I wanted to see how I could adapt them to use Arktype where they expect a schema.

I never wanted to have a tight coupling between the libraries and Zod, so instead of having Zod as a dependency, I'd expect a subset of a Zod schema.

// instead of:
function validate<T>(schema: ZodSchema<T>): T {
  // ...
}

// I'd expect something like:
function validate<T>(schema: { parse: (val: unknown) => T }): T {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

The validate function now accepts a generic schema that only requires a parse method. This decouples our code from Zod, allowing us to use other libraries with minimal changes.

It turns out this has just proven to be a great idea.

The libraries I'm adapting for

In this project, I'm using two Zod-based libraries, which are:

make-service

This lib uses Zod to validate an API response - among other nice features -, which is useful to ensure the data expectations are correct.

Check out a code sample without the library:

const response = await fetch('https://example.com/api/users', {
  headers: {
    Authorization: 'Bearer 123',
  },
})
const users = await response.json()
//    ^? any
Enter fullscreen mode Exit fullscreen mode

And with it:

const service = makeService('https://example.com/api', {
  headers: {
    Authorization: 'Bearer 123',
  },
})

const response = await service.get('/users')
const users = await response.json(usersSchema)
//    ^? User[]
Enter fullscreen mode Exit fullscreen mode

composable-functions

This is a library to allow function composability and monadic error handling. If you don't know it yet, think about a smaller/simpler Effect which has runtime type checking backed in by Zod.

Here follows a didactic example of how to define a function that doubles a number and does runtime type-checking:

import { withSchema } from 'composable-functions'
const safeDouble = withSchema(z.number())((n) => n * 2)
Enter fullscreen mode Exit fullscreen mode

The difference between the libraries

When going into the libs source we can see that they use different subsets of Zod. The first one expects the already mentioned:

type Schema<T> = { parse: (d: unknown) => T }
Enter fullscreen mode Exit fullscreen mode

While the second one expects the following code which is a subset of Zod's SafeParseError | SafeParseSuccess:

type ParserSchema<T = unknown> = {
  safeParse: (a: unknown) =>
    | {
        success: true
        data: T
      }
    | {
        success: false
        error: {
          issues: ReadonlyArray<{
            path: PropertyKey[]
            message: string
          }>
        }
      }
}
Enter fullscreen mode Exit fullscreen mode

Which is a bit more complex, but still, it's just a subset of Zod.

TDD: Type Driven Development 😄

When investigating on how to extract the type out of an Arktype schema, I found out you can do:

import { Type } from 'arktype'

type Example = Type<{ name: string }>
type Result = Example['infer']
//   ^? { name: string }
Enter fullscreen mode Exit fullscreen mode

Therefore, I could go on and create one adaptor for each library but this is a case where I can join both expectations in the same function. In fact, what I need is a function that conforms to this return type:

import { Type } from 'arktype'
import { ParserSchema } from 'composable-functions'
import { Schema } from 'make-service'

declare function ark2zod<T extends Type>(
  schema: T,
): Schema<T['infer']> & ParserSchema<T['infer']>
Enter fullscreen mode Exit fullscreen mode

The implementation

Having started from the types above, the solution was quite straightforward.
I hope the code with comments below speaks for itself:

import { type, Type } from 'arktype'
import { ParserSchema } from 'composable-functions'
import { Schema } from 'make-service'

function ark2zod<T extends Type>(
  schema: T,
): Schema<T['infer']> & ParserSchema<T['infer']> {
  return {
    // For `make-service` lib:
    parse: (val) => schema.assert(val),
    // For `composable-functions` lib:
    safeParse: (val: unknown) => {
      // First, we parse the value with arktype
      const data = schema(val)
      // If the parsing fails, we only need what ParserSchema expects
      if (data instanceof type.errors) {
        // The ArkErrors will have a shape similar to Zod's issues
        return { success: false, error: { issues: data } }
      }
      // If the parsing succeeds, we return the successful side of ParserSchema
      return { success: true, data }
    },
  }
}

export { ark2zod }
Enter fullscreen mode Exit fullscreen mode

Special thanks to David Blass - ArkType's creator - who reviewed and suggested a leaner version of this adapter.

Usage

Using the function above I was able to create my composable functions with make-service's service using Arktype schemas seamlessly:

import { type } from 'arktype'
import { withSchema } from 'composable-functions'
import { ark2zod } from '~/framework/common'
import { blogService } from '~/services'

const paramsSchema = type({
  slug: 'string',
  username: 'string',
})

const postSchema = type({
  title: 'string',
  body: 'string',
  // ...
})

const getPost = withSchema(ark2zod(paramsSchema))(
  async ({ slug, username }) => {
    const response = await blogService.get('articles/:username/:slug', {
      params: { slug, username },
    })
    const json = await response.json(ark2zod(postSchema))
    return json
  },
)

export { getPost }
Enter fullscreen mode Exit fullscreen mode

When using the getPost function, my result will be strongly typed at both type and runtime levels or it will be a Failure:

export function loader({ params }: LoaderFunctionArgs) {
  const result = await getPost(params)
  if (!result.success) {
    console.error(result.errors)
    throw notFound()
  }
  return result.data
  //            ^? { title: string, body: string, ... }
}
Enter fullscreen mode Exit fullscreen mode

Final thoughts

I hope this post was helpful to you, not only to understand how to adapt Zod-based libraries but also to understand how we at Seasoned approach problems. If you have any questions or have gone through a similar migration, I’d love to hear about your experiences and any tips you might have.

One thing I can assure you is that we love TS and we like to be certain about our data, be it at compile time or runtime.

. . . . . . . . . . . . . . . . . . . . . . . . .