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 {
// ...
}
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
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[]
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)
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 }
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
}>
}
}
}
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 }
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']>
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 }
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 }
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, ... }
}
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)
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.
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
Always bringing us great content, thanks Guga!
Great, Guga!