Published on

How to Transform Object to Array in Zod Parsing

Authors
  • Name
    Ripal & Zalak
    Twitter

When working with APIs, you might encounter inconsistent data structures, such as receiving an array of objects when there are multiple items and a single object when there's only one. This inconsistency can cause issues with schema validation libraries like Zod, which expect a consistent structure. Here's how to handle such scenarios effectively.

Problem: Handling Mixed API Responses

Consider an API that returns the following data:

  • Single field:
{
  "fields": { "fullName": "fieldFullname", "type": "fieldType" }
}
  • Multiple fields:
{
  "fields": [
    { "fullName": "fieldFullname", "type": "fieldType" },
    { "fullName": "fieldFullname", "type": "fieldType" }
  ]
}

Using Zod, a straightforward schema might look like this:

import { z } from 'zod'

export const metadataFieldSchema = z.object({
  fullName: z.string().optional(),
  type: z.string().optional(),
})

export const sObjectMetadataSchema = z.object({
  fields: z.array(metadataFieldSchema).optional(),
})

Issue: When the API returns a single object instead of an array, the schema validation fails with an error like:

{
  "code": "invalid_type",
  "expected": "array",
  "received": "object",
  "message": "Expected array, received object"
}

Solution: Transform Object to Array with Zod

To handle this, you can use Zod's transform or preprocess methods to normalize the data before validation.

Using .transform()

The .transform() method modifies the input before validation:

const metadataFieldSchema = z.object({
  fullName: z.string(),
  type: z.string(),
})

export const sObjectMetadataSchema = z.object({
  fields: z
    .union([metadataFieldSchema, z.array(metadataFieldSchema)])
    .transform((rel) => (Array.isArray(rel) ? rel : [rel])),
})

Here’s how it works:

  • If fields is an array, it remains unchanged.
  • If fields is an object, it is wrapped in an array.

Using .preprocess()

The .preprocess() method applies a transformation before validation starts:

const metadataFieldSchema = z.object({
  fullName: z.string(),
  type: z.string(),
})

export const sObjectMetadataSchema = z.object({
  fields: z.preprocess(
    (rel) => (Array.isArray(rel) ? rel : [rel]),
    z.array(metadataFieldSchema).optional()
  ),
})

Why .preprocess()?

  • Runs before any validation occurs, ensuring consistent data for subsequent validation.
  • Ideal for type coercion or normalization.

Advanced Example: Typed Transformation

For a strongly typed solution in TypeScript, you can wrap the transformation in a reusable function:

import { z, ZodTypeAny } from 'zod'

const arrayify = <T extends ZodTypeAny>(schema: T) => {
  return z.preprocess((val) => (Array.isArray(val) ? val : [val]), z.array(schema))
}

const metadataFieldSchema = z.object({
  fullName: z.string(),
  type: z.string(),
})

export const sObjectMetadataSchema = z.object({
  fields: arrayify(metadataFieldSchema).optional(),
})

This approach:

  • Encapsulates the transformation logic.
  • Ensures reusability for other schemas.

FAQs

1. Why use .preprocess() over .transform()?

.preprocess() is applied before validation begins, making it ideal for correcting or normalizing input data. Use .transform() for modifying validated data.

2. Can Zod validate both single objects and arrays without transformation?

Yes, by using z.union(). However, without transformation, you won’t be able to standardize the data structure for downstream usage.

3. How do I handle deeply nested fields?

Use .preprocess() or .transform() recursively to normalize nested structures before validation.

4. What if the API response contains invalid data?

Zod will still validate the data according to the schema. If validation fails, you can catch the error and handle it appropriately.