Using Arktype in Place of Zod - How to Adapt Parsers
Tags: typescript, zod, arktype, migration
Published: 2024-06-12 16:37:14

Ever since I started using [Zod](https://github.com/colinhacks/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](https://github.com/gustavoguichard/string-ts/), [co-created](https://github.com/seasonedcc/composable-functions/), and [worked](https://github.com/seasonedcc/remix-forms) 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](https://github.com/arktypeio/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.

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

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

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](https://github.com/gustavoguichard/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**:

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

**And with it**:

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

const response = await service.get('/users')
const users = await response.json(usersSchema)
//    ^? User[]
```

### [composable-functions](https://github.com/seasonedcc/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](https://github.com/Effect-TS) 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:

```ts
import { withSchema } from 'composable-functions'
const safeDouble = withSchema(z.number())((n) => n * 2)
```

## 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:

```ts
type Schema<T> = { parse: (d: unknown) => T }
```

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

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

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:

```ts
import { Type } from 'arktype'

type Example = Type<{ name: string }>
type Result = Example['infer']
//   ^? { name: string }
```

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:

```ts
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']>
```

## The implementation

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

```ts
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 }
```

> Special thanks to [David Blass](https://x.com/ssalbdivad) - 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:

```ts
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 }
```

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

```ts
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, ... }
}
```

## 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](https://seasoned.cc) 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.