Mapping data with Zod
The problem
Mapping data in JavaScript and TypeScript is not always straightforward. You may need a series of checks or switch statements to determine which shape the input has, then write validation for each shape.
Getting the types right adds another layer of work. The usual tricks can make the mapper harder to understand and maintain.
The result is a lot of code around a fairly simple job. Zod can handle the validation while you map the data.
const user = {
id: '123',
firstName: 'Stijn',
lastName: 'Van Hulle',
email: '[email protected]',
} as const
const twitterUser = {
id: '123',
first: 'Stijn',
last: 'Van Hulle',
email: '[email protected]',
} as const
const googleUser = {
uuid: '123',
info: {
firstName: 'Stijn',
lastName: 'Van Hulle',
},
email: '[email protected]',
} as const
// no type support
const mapData = (data: any) => {
if (data.info) {
// google user
return {
id: user.uuid,
value: user.info.firstName + ' ' + user.info.lastName,
}
} else if (data.first && data.last) {
return {
id: user.id,
value: user.first + ' ' + user.last,
}
} else if (data.firstName && data.lastName) {
return {
id: user.id,
value: user.firstName + ' ' + user.lastName,
}
}
}
mapData(googleUser) /* => {
id: '123',
value: 'Stijn Van Hulle'
}
*/
What is Zod?
Zod describes itself as "TypeScript-first schema validation with static type inference."
You can use Zod to validate forms, define schemas, and validate API input. tRPC is one example of a library that uses Zod. Define a schema on the back end, then reuse it on the front end when making API calls.
There are many TypeScript schema validation libraries, but Zod also infers TypeScript types from your schemas. That makes it a good fit for a typed data mapper.
Zod primitives
The following example checks whether a value is a string.
import { z } from 'zod'
// creating a schema for strings
const mySchema = z.string()
// parsing
mySchema.parse('tuna') // => "tuna"
mySchema.parse(12) // => throws ZodError
// "safe" parsing (doesn't throw error if validation fails)
mySchema.safeParse('tuna') // => { success: true; data: "tuna" }
mySchema.safeParse(12) // => { success: false; error: ZodError }
Zod object
Zod can also validate objects against a defined shape.
import { z } from 'zod'
const User = z.object({
username: z.string(),
})
User.parse({ username: 'Ludwig' })
// extract the inferred type
type User = z.infer<typeof User>
// { username: string }
User.parse({ firstname: 'firstname' }) // => throws ZodError
Zod transform
Zod can transform a value from one shape into another. This example extracts the domain from an email address.
const emailToDomain = z
.string()
.email()
.transform((val) => val.split('@')[1])
emailToDomain.parse('[email protected]') // => example.com
Mapping data
Simple Zod mapper
With the basics in place, we can build a mapper schema using Zod primitives and transform.
- Start with an input object and map it to an object containing only id and value (the user's full name).
const user = {
id: '123',
firstName: 'Stijn',
lastName: 'Van Hulle',
email: '[email protected]',
}
- Use
transformto create the mapped object.
import z from 'zod'
export const userSchema = z
.object({
id: z.string(),
firstName: z.string(),
lastName: z.string(),
email: z.string(),
})
.transform((user) => {
return {
id: user.id,
value: user.firstName + ' ' + user.lastName,
}
})
- Call
.parsewith user. It returns the mapped data.
userSchema.parse(user) /* => {
id: '123',
value: 'Stijn Van Hulle'
}
*/
Multiple inputs mapper
What if the inputs have different shapes and schemas?
import z from 'zod'
const user = {
id: '123',
firstName: 'Stijn',
lastName: 'Van Hulle',
email: '[email protected]',
}
const twitterUser = {
id: '123',
first: 'Stijn',
last: 'Van Hulle',
email: '[email protected]',
}
export const userSchema = z
.object({
id: z.string(),
firstName: z.string(),
lastName: z.string(),
email: z.string(),
})
.transform((user) => {
return {
id: user.id,
value: user.firstName + ' ' + user.lastName,
}
})
export const twitterUserSchema = z
.object({
id: z.string(),
first: z.string(),
last: z.string(),
email: z.string(),
})
.transform((user) => {
return {
id: user.id,
value: user.first + ' ' + user.last,
}
})
userSchema.parse(user) /* => {
id: '123',
value: 'Stijn Van Hulle'
}
*/
twitterUserSchema.parse(twitterUser) /* => {
id: '123',
value: 'Stijn Van Hulle'
}
*/
Multiple unknown inputs mapper
What if the input shape is unknown? Let's say we have 2 API calls:
- One to our database
- One to an external API(for example Twitter).
We could check where the data came from, but that brings back the same maintenance problem. We would still need conditional logic to inspect the input before converting it.
import z from 'zod'
const user = {
id: '123',
firstName: 'Stijn',
lastName: 'Van Hulle',
email: '[email protected]',
}
const twitterUser = {
id: '123',
first: 'Stijn',
last: 'Van Hulle',
email: '[email protected]',
}
export const userSchema = z
.object({
id: z.string(),
firstName: z.string(),
lastName: z.string(),
email: z.string(),
})
.transform((user) => {
return {
id: user.id,
value: user.firstName + ' ' + user.lastName,
}
})
export const twitterUserSchema = z
.object({
id: z.string(),
first: z.string(),
last: z.string(),
email: z.string(),
})
.transform((user) => {
return {
id: user.id,
value: user.first + ' ' + user.last,
}
})
const mapData = (data: any) => {
if (data.first) {
// twitter
twitterUserSchema.parse(data)
} else {
// database user
return userSchema.parse(data)
}
}
mapData(twitteruser) /* => {
id: '123',
value: 'Stijn Van Hulle'
}
*/
mapData(user) /* => {
id: '123',
value: 'Stijn Van Hulle'
}
*/
Zod unions
This works for two sources, but what happens when a third source appears, such as Google user data? Adding another conditional would make the mapper grow with every source.
Instead, let Zod try each schema and infer the matching output type.
Zod unions, including the .or method, let you describe these alternatives in one schema.
const schema = z.string().or(z.number()) // string | number
// equivalent to
z.union([z.string(), z.number()])
stringOrNumber.parse('foo') // passes
stringOrNumber.parse(14) // passes
End result
Combine unions with transforms, and Zod can find the matching schema and return one consistent shape.
So with our previous example, we can simplify that to the following:
const user = {
id: '123',
firstName: 'Stijn',
lastName: 'Van Hulle',
email: '[email protected]',
} as const
const twitterUser = {
id: '123',
first: 'Stijn',
last: 'Van Hulle',
email: '[email protected]',
} as const
const googleUser = {
uuid: '123',
info: {
firstName: 'Stijn',
lastName: 'Van Hulle',
},
email: '[email protected]',
} as const
import z from 'zod'
export const userSchema = z
.object({
id: z.string(),
firstName: z.string(),
lastName: z.string(),
email: z.string(),
})
.transform((user) => {
return {
id: user.id,
value: user.firstName + ' ' + user.lastName,
}
})
export const twitterUserSchema = z
.object({
id: z.string(),
first: z.string(),
last: z.string(),
email: z.string(),
})
.transform((user) => {
return {
id: user.id,
value: user.first + ' ' + user.last,
}
})
export const googleUserSchema = z
.object({
uuid: z.string(),
info: z.object({
firstName: z.string(),
lastName: z.string(),
}),
email: z.string(),
})
.transform((user) => {
return {
id: user.uuid,
value: user.info.firstName + ' ' + user.info.lastName,
}
})
const mapData = (data: any) => twitterUserSchema.or(userSchema).or(googleUserSchema).parse(data)
export const users = [mapData(twitterUser), mapData(user), mapData(googleUser)]
Summary
You can add as many non-overlapping schemas as you need. Zod throws an error when none matches the input, while the result keeps a consistent type.
This approach is useful for mapping JavaScript data, but keep the mapper easy to follow. Too many schemas and types make an application harder to maintain, so add a union only when it reflects a real input source.
Zod can do much more, so explore the library when you need it. For a deeper look, see this Zod tutorial.