DEV Community

Cover image for Using Arktype in Place of Zod - How to Adapt Parsers
Gustavo Guichard (Guga) for Seasoned

Posted on • Edited on

Using Arktype in Place of Zod - How to Adapt Parsers

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.

Top comments (4)

Collapse
 
ssalbdivad profile image
David Blass

This is a great example of how ArkType can integrate with libraries and provide a ton of inference capabilities out of the box!

I'm particularly excited about the fact that since ArkType's primary definition format is just type-safe objects and strings, library authors could actually accept ArkType definitions in their own API without requiring their dependents to import arktype at all!

This would be fundamentally impossible with an approach like Zod's that can only define schemas with non-serializable functions like z.object.

Collapse
 
gugaguichard profile image
Gustavo Guichard (Guga)

Yes David! I've been thinking we should start building our libraries on the other way around: ArkType as primary/ideal API and possible adapters for popular parsers such as Zod ;D

Collapse
 
marioaj profile image
Mário Alfredo Jorge

Always bringing us great content, thanks Guga!

Collapse
 
ravencodde profile image
Raven Code

Great, Guga!