---
title: Use preprocess() for Data Normalization
impact: MEDIUM
impactDescription: Validating before cleaning data causes false rejections; preprocess() normalizes input before schema validation runs
tags: compose, preprocess, normalize, cleaning
---

## Use preprocess() for Data Normalization

When incoming data needs normalization before validation (trimming whitespace, parsing JSON strings, converting formats), use `z.preprocess()`. This runs a function on the raw input before Zod's type checking, allowing you to clean data that would otherwise fail validation.

**Incorrect (validation fails on unnormalized data):**

```typescript
import { z } from "zod";

const userSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  config: z.object({
    theme: z.string(),
  }),
});

// Raw form data
const formData = {
  name: "  John Doe  ", // Has whitespace
  email: "JOHN@EXAMPLE.COM", // Uppercase
  config: '{"theme": "dark"}', // JSON string, not object
};

userSchema.parse(formData);
// ZodError: Expected object, received string at "config"
```

**Correct (using preprocess):**

```typescript
import { z } from "zod";

// Preprocess normalizes before validation
const trimmedString = z.preprocess(val => (typeof val === "string" ? val.trim() : val), z.string());

const lowercaseEmail = z.preprocess(
  val => (typeof val === "string" ? val.toLowerCase().trim() : val),
  z.string().email()
);

const jsonObject = z.preprocess(
  val => {
    if (typeof val === "string") {
      try {
        return JSON.parse(val);
      } catch {
        return val; // Let Zod report the error
      }
    }
    return val;
  },
  z.object({ theme: z.string() })
);

const userSchema = z.object({
  name: trimmedString.pipe(z.string().min(1)),
  email: lowercaseEmail,
  config: jsonObject,
});

const formData = {
  name: "  John Doe  ",
  email: "JOHN@EXAMPLE.COM",
  config: '{"theme": "dark"}',
};

const user = userSchema.parse(formData);
// { name: 'John Doe', email: 'john@example.com', config: { theme: 'dark' } }
```

**Common preprocessing patterns:**

```typescript
// Trim all strings
const trimmedString = z.preprocess(val => (typeof val === "string" ? val.trim() : val), z.string());

// Parse numeric strings
const numericString = z.preprocess(
  val => (typeof val === "string" ? Number(val) : val),
  z.number()
);

// Parse boolean-like values
const booleanLike = z.preprocess(val => {
  if (val === "true" || val === "1" || val === 1) return true;
  if (val === "false" || val === "0" || val === 0) return false;
  return val;
}, z.boolean());

// Parse date strings
const dateString = z.preprocess(val => (typeof val === "string" ? new Date(val) : val), z.date());

// Split comma-separated strings into arrays
const csvArray = z.preprocess(
  val => (typeof val === "string" ? val.split(",").map(s => s.trim()) : val),
  z.array(z.string())
);
```

**Preprocess vs Transform:**

```typescript
// preprocess() runs BEFORE type checking
// Use for: Normalizing input format before validation
z.preprocess(val => String(val).trim(), z.string().min(1));

// transform() runs AFTER type checking
// Use for: Converting validated data to different format
z.string().transform(s => s.toUpperCase());

// Order of operations:
// 1. preprocess receives raw unknown input
// 2. Zod validates the preprocessed value
// 3. transform converts the validated value
```

**When NOT to use this pattern:**

- When `.coerce` methods handle the conversion (simpler)
- When transformation should happen after validation (use transform)
- When normalization could hide validation errors

Reference: [Zod API - preprocess](https://zod.dev/api#preprocess)
