TypeScript 54 🔷 Type-Safe Validation with Zod and io-ts
TypeScript’s type system is erased at runtime. When data arrives from an API, a form, or a file, the type annotation is a claim, not a check. A response typed as User is not validated to be a User — if the server sends a different shape, the mismatch appears somewhere deep in the application, far from the boundary where it entered. This is the problem that runtime validation libraries solve. They define schemas that exist as values at runtime and as types at compile time, so a single definition drives both the check and the type. Zod and io-ts are the two dominant libraries in the TypeScript ecosystem, and they represent opposite design philosophies: Zod is ergonomic, chainable, and mainstream; io-ts is functional, composable, and built on codecs. This chapter covers both, when to choose each, and the patterns that make validation reliable.
Key point: A validation schema is a value that describes a shape and checks data against it. Zod defines schemas with a chainable API and infers the TypeScript type from the schema with z.infer. io-ts defines schemas as codecs — bidirectional transformers with a .decode() method — and extracts the type with t.TypeOf. Zod throws on failure by default, with safeParse as the non-throwing alternative. io-ts returns an Either from fp-ts, with Left for errors and Right for success. For most new projects, Zod is the default; io-ts is for teams already invested in functional programming with fp-ts .
Why runtime validation matters
The boundary is where unvalidated data enters. An API response, a form submission, a URL parameter, a file read, a WebSocket message — all of these are unknown until they are checked. Casting them with as User is a lie to the compiler; the data may not match.
The cost of a cast. A response cast to User compiles, and the code that reads user.name compiles. If the server sent { fullName: "Alice" } instead, user.name is undefined, and the failure surfaces wherever name is used — a rendered component, a database write, a calculation. The error is far from the cause, and the stack trace points to the symptom .
What validation buys. A schema checks the data at the boundary. If it matches, the parsed value is typed and safe to use. If it does not, the failure is reported at the boundary, where it can be handled — a retry, an error message, a log entry. The error is local to the cause.
Why the type must be derived from the schema. If the type and the schema are written separately, they can drift. z.infer and t.TypeOf derive the type from the schema, so changing the schema changes the type. There is one source of truth, and it is the schema .
Why the library choice matters less than the practice. Zod and io-ts both solve the problem. The choice is about the team’s mental model — chainable and ergonomic, or functional and composable. The practice of validating at the boundary is what matters; the library is the mechanism.
Why validation is not the same as parsing. Parsing converts a string into a structured value —
JSON.parse,parseInt,new Date. Validation checks that the structured value matches an expected shape. A schema does both: it validates and, with transforms, it can also parse. But the core job is validation — rejecting data that does not match.
Zod: the ergonomic default
Zod defines schemas with a chainable, method-based API. The schema is a value, and the type is inferred from it.
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().min(0).optional(),
});
type User = z.infer<typeof UserSchema>;
// { id: string; name: string; email: string; age?: number }
The schema is a single expression that reads top to bottom. z.object defines the shape, z.string() defines a string, .min(1) adds a constraint, and .email() adds a format check. The z.infer utility extracts the TypeScript type, which is exactly the shape the schema describes.
Why the chainable API is the main selling point. The schema reads like the type it describes. z.string().min(3).max(20) is a string between 3 and 20 characters. There is no separate type declaration and no manual mapping between the two. The schema is the specification, and the type follows .
Parsing with parse and safeParse. parse validates and returns the parsed data, throwing a ZodError on failure. safeParse returns a result object with a success boolean and either data or error, without throwing.
const data = { id: '1', name: 'Alice', email: 'alice@example.com' };
const user = UserSchema.parse(data);
// user is User, or ZodError thrown
const result = UserSchema.safeParse(data);
if (result.success) {
console.log(result.data); // User
} else {
console.log(result.error.issues); // ZodIssue[]
}
safeParse is the pattern for handling failures gracefully, and parse is for the cases where a failure is a programming error that should throw .
Structured errors. Zod’s errors carry a path, a message, and a code for each issue. The path identifies the field, and the message describes the failure. This structure is what makes form validation and API error responses possible .
const result = UserSchema.safeParse({ id: 1, name: '', email: 'bad' });
if (!result.success) {
console.log(result.error.issues);
// [
// { path: ['id'], message: 'Expected string, received number', code: 'invalid_type' },
// { path: ['name'], message: 'String must contain at least 1 character(s)', code: 'too_small' },
// { path: ['email'], message: 'Invalid email', code: 'invalid_string' },
// ]
}
Refinements and transforms. refine adds custom validation, and transform changes the value during parsing. The two are how Zod handles logic that the built-in validators cannot express.
const PasswordSchema = z
.string()
.min(8)
.refine((val) => /[A-Z]/.test(val), { message: 'Must contain uppercase' });
const EmailSchema = z
.email()
.transform((email) => email.toLowerCase().trim());
The refine adds a predicate and a message. The transform normalizes the value, so the parsed output is the normalized form .
Discriminated unions. Zod handles discriminated unions with a dedicated constructor, which checks the discriminant and narrows the type accordingly.
const ShapeSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('circle'), radius: z.number() }),
z.object({ kind: z.literal('square'), side: z.number() }),
]);
The discriminatedUnion requires a discriminant key and checks that each variant has a unique literal value. The parsed type is the narrowed union .
io-ts: the functional alternative
io-ts defines schemas as codecs — values that encode and decode. The API is functional and compositional, built on the fp-ts ecosystem.
import * as t from 'io-ts';
import { PathReporter } from 'io-ts/PathReporter';
const UserCodec = t.type({
id: t.string,
name: t.string,
email: t.string,
age: t.union([t.number, t.undefined]),
});
type User = t.TypeOf<typeof UserCodec>;
// { id: string; name: string; email: string; age: number | undefined }
The t.type combinator defines an object shape, and t.string and t.number are the primitive codecs. The t.TypeOf utility extracts the static type, which is the codec’s decoded type.
Decoding with decode and Either. The decode method returns an Either from fp-ts — Right for success and Left for failure. The caller handles both branches.
import { isLeft } from 'fp-ts/Either';
const result = UserCodec.decode(data);
if (isLeft(result)) {
console.log(PathReporter.report(result));
} else {
console.log(result.right); // User
}
The PathReporter converts the error structure into human-readable strings. The isLeft guard narrows the result to the error branch .
Why the functional style matters. io-ts treats validation as a pure function from unknown to Either<Errors, T>. There are no exceptions and no hidden state. The result is a value that can be composed with other functional operations — pipe, fold, map. This is the alignment with fp-ts that makes io-ts attractive to functional teams .
Custom codecs. A custom type is defined with the t.Type constructor, which takes a name, an is guard, a validate function, and an encode function.
const PositiveNumber = new t.Type<number, number, unknown>(
'PositiveNumber',
(u): u is number => typeof u === 'number' && u > 0,
(u, c) => (typeof u === 'number' && u > 0 ? t.success(u) : t.failure(u, c)),
t.identity,
);
The four arguments are the codec’s name, the type guard, the validation logic, and the encoder. The t.identity encoder is used when the encoded and decoded types are the same .
Refinements with t.refinement. The refinement combinator adds a predicate to an existing codec.
const PasswordCodec = t.refinement(
t.string,
'Password',
(s) => s.length > 8,
);
The t.refinement takes the base codec, a name, and the predicate. It is the functional equivalent of Zod’s refine .
Tagged unions with t.taggedUnion. io-ts handles discriminated unions with the taggedUnion combinator, which takes the discriminant key and an array of codecs.
const ShapeCodec = t.taggedUnion('kind', [
t.type({ kind: t.literal('circle'), radius: t.number }),
t.type({ kind: t.literal('square'), side: t.number }),
]);
The t.taggedUnion is the io-ts equivalent of z.discriminatedUnion .
Choosing between Zod and io-ts
The choice is not about capability — both validate data and infer types. It is about the team’s mental model and the ecosystem the project belongs to.
Zod is the default for most projects. It has a gentler learning curve, a more readable API, better error messages out of the box, and broader adoption. The ecosystem is larger — React Hook Form resolvers, tRPC integration, OpenAPI generation, and community packages. For a new TypeScript project, Zod is the safe default .
io-ts is for functional teams. If the project already uses fp-ts, io-ts fits naturally. The codec model, the Either result, and the compositional combinators align with functional patterns. The cost is verbosity and a steeper learning curve for developers not familiar with the style. If the team does not already think in these terms, the friction outweighs the benefit .
Performance is a secondary concern. Zod and io-ts are comparable in performance for typical workloads. Neither is the fastest validation library — typia and ajv are faster — but the difference rarely matters for application-level validation. The choice should be made on ergonomics and team fit, not on benchmarks .
Ecosystem support. Zod has first-class integrations with React Hook Form (@hookform/resolvers/zod), tRPC, and OpenAPI generators like zod-to-openapi. io-ts has integrations with Express middleware, fp-ts pipelines, and functional HTTP frameworks. The ecosystem is part of the choice .
Why the schema-first approach is the same in both. Whatever the library, the pattern is: define the schema once, derive the type from it, validate at the boundary. The schema is the single source of truth for both the runtime check and the compile-time type. This is the principle that makes either library work.
Complete Example Session
// ============================================
// PART 1: ZOD SCHEMA AND TYPE
// ============================================
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().min(0).optional(),
});
type User = z.infer<typeof UserSchema>;
// ============================================
// PART 2: ZOD PARSING
// ============================================
const validData = {
id: '550e8400-e29b-41d4-a716-446655440000',
name: 'Alice',
email: 'alice@example.com',
};
const user = UserSchema.parse(validData);
// user is User
const result = UserSchema.safeParse({ id: 'not-a-uuid', name: '', email: 'bad' });
if (!result.success) {
console.log(result.error.issues);
// [
// { path: ['id'], message: 'Invalid uuid', code: 'invalid_string' },
// { path: ['name'], message: 'String must contain at least 1 character(s)', code: 'too_small' },
// { path: ['email'], message: 'Invalid email', code: 'invalid_string' },
// ]
}
// ============================================
// PART 3: ZOD REFINEMENT AND TRANSFORM
// ============================================
const PasswordSchema = z
.string()
.min(8)
.refine((val) => /[A-Z]/.test(val), { message: 'Must contain uppercase' })
.refine((val) => /[0-9]/.test(val), { message: 'Must contain a number' });
const EmailSchema = z
.email()
.transform((email) => email.toLowerCase().trim());
const normalized = EmailSchema.parse(' ALICE@EXAMPLE.COM ');
// 'alice@example.com'
// ============================================
// PART 4: ZOD DISCRIMINATED UNION
// ============================================
const ShapeSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('circle'), radius: z.number() }),
z.object({ kind: z.literal('square'), side: z.number() }),
]);
const shape = ShapeSchema.parse({ kind: 'circle', radius: 5 });
// { kind: 'circle', radius: 5 }
// ============================================
// PART 5: IO-TS CODEC AND TYPE
// ============================================
import * as t from 'io-ts';
import { PathReporter } from 'io-ts/PathReporter';
import { isLeft } from 'fp-ts/Either';
const UserCodec = t.type({
id: t.string,
name: t.string,
email: t.string,
age: t.union([t.number, t.undefined]),
});
type IoUser = t.TypeOf<typeof UserCodec>;
// ============================================
// PART 6: IO-TS DECODING
// ============================================
const decodeResult = UserCodec.decode(validData);
if (isLeft(decodeResult)) {
console.log(PathReporter.report(decodeResult));
} else {
console.log(decodeResult.right); // IoUser
}
// ============================================
// PART 7: IO-TS CUSTOM CODEC
// ============================================
const PositiveNumber = new t.Type<number, number, unknown>(
'PositiveNumber',
(u): u is number => typeof u === 'number' && u > 0,
(u, c) => (typeof u === 'number' && u > 0 ? t.success(u) : t.failure(u, c)),
t.identity,
);
const ConfigCodec = t.type({
port: PositiveNumber,
host: t.string,
});
// ============================================
// PART 8: IO-TS REFINEMENT
// ============================================
const PasswordCodec = t.refinement(
t.string,
'Password',
(s) => s.length > 8,
);
// ============================================
// PART 9: IO-TS TAGGED UNION
// ============================================
const ShapeCodec = t.taggedUnion('kind', [
t.type({ kind: t.literal('circle'), radius: t.number }),
t.type({ kind: t.literal('square'), side: t.number }),
]);
// ============================================
// PART 10: VALIDATING AT THE BOUNDARY
// ============================================
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
return UserSchema.parse(data); // validate before returning
}
The ten parts cover the two libraries, their schemas and types, their parsing and decoding, their refinements and custom codecs, and the boundary pattern that both share.
Quick Reference
Zod Core
| Method | Purpose |
|---|---|
z.object({...}) | Object schema |
z.string(), z.number(), z.boolean() | Primitives |
.min(), .max() | Constraints |
.email(), .uuid() | Format checks |
.optional() | Optional field |
.refine(fn, opts) | Custom validation |
.transform(fn) | Transform value |
z.discriminatedUnion(key, [...]) | Discriminated union |
z.infer<typeof Schema> | Extract type |
Zod Parsing
| Method | Behavior |
|---|---|
.parse(data) | Returns data or throws |
.safeParse(data) | Returns { success, data/error } |
.parseAsync(data) | Async validation |
io-ts Core
| Codec | Purpose |
|---|---|
t.type({...}) | Object codec |
t.string, t.number, t.boolean | Primitives |
t.union([...]) | Union |
t.intersection([...]) | Intersection |
t.partial({...}) | Optional fields |
t.refinement(codec, name, fn) | Custom validation |
t.taggedUnion(key, [...]) | Discriminated union |
t.TypeOf<typeof Codec> | Extract type |
io-ts Decoding
| Method | Returns |
|---|---|
.decode(data) | Either<Errors, T> |
isLeft(result) | Type guard for errors |
isRight(result) | Type guard for success |
PathReporter.report(result) | Human-readable errors |
Choosing
| Factor | Zod | io-ts |
|---|---|---|
| Learning curve | Low | High |
| API style | Chainable | Functional |
| Error format | Structured | Either |
| Ecosystem | Broad | fp-ts |
| Default choice | ✅ | Functional teams |
Best Practices
✅ Do This:
// Derive the type from the schema
type User = z.infer<typeof UserSchema>; // ✅
// Validate at the boundary
return UserSchema.parse(data); // ✅
// Use safeParse for graceful handling
const result = UserSchema.safeParse(data); // ✅
// Use refine for custom logic
z.string().refine((v) => v.length > 8, { message: 'Too short' }); // ✅
// Use transform for normalization
z.email().transform((e) => e.toLowerCase()); // ✅
// Use discriminated unions for tagged data
z.discriminatedUnion('kind', [...]); // ✅
// io-ts: use Either pattern
if (isLeft(result)) { ... } else { ... } // ✅
❌ Don’t Do This:
// Don't cast without validation
const user = (await response.json()) as User; // a lie // ⚠️
// Don't write the type and schema separately
interface User { ... }
const UserSchema = z.object({ ... }); // can drift // ⚠️
// Don't ignore validation errors
const result = UserSchema.safeParse(data);
// result.success may be false // ⚠️
// Don't use `parse` when the caller should handle failure
// Throwing is fine for programming errors, not user input // ⚠️
// Don't use io-ts without fp-ts familiarity
// The Either pattern requires understanding // ⚠️
// Don't over-validate the same data repeatedly
// Validate once at the boundary // ⚠️
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
| Cast instead of validate | Runtime crash | Use a schema |
| Type and schema drift | Wrong type | Derive type from schema |
Ignoring safeParse result | Silent failure | Check success |
| Throwing on user input | Poor UX | Use safeParse |
| Over-validating | Performance | Validate at the boundary |
| io-ts without fp-ts | Confusion | Use Zod for non-functional teams |
Missing PathReporter | Unreadable errors | Import and call it |
| Discriminated union without literal | No narrowing | Use z.literal |
Real-World Examples
1. API response validation
const data = await response.json();
return UserSchema.parse(data);
2. Form validation
const result = FormSchema.safeParse(formData);
if (!result.success) showErrors(result.error.issues);
3. Environment variables
const EnvSchema = z.object({ DATABASE_URL: z.string().url() });
const env = EnvSchema.parse(process.env);
4. URL parameters
const ParamsSchema = z.object({ id: z.coerce.number() });
const params = ParamsSchema.parse(req.params);
5. Nested objects
const PostSchema = z.object({
id: z.string(),
author: UserSchema,
tags: z.array(z.string()),
});
6. Refined string
const SlugSchema = z.string().regex(/^[a-z0-9-]+$/);
7. Transformed input
const DateSchema = z.string().transform((s) => new Date(s));
8. Discriminated union
z.discriminatedUnion('type', [A, B, C]);
9. io-ts decode pipeline
pipe(codec.decode(data), fold(onError, onSuccess));
10. io-ts with PathReporter
if (isLeft(result)) console.log(PathReporter.report(result));
Visual: The Boundary
┌──────────────────────────────────────────────────────────┐
│ SERVER │
│ │ │
│ │ JSON │
│ ▼ │
│ BOUNDARY │
│ │ │
│ ├── response.json() ──► unknown │
│ │ │
│ ├── schema.parse(data) │
│ │ │ │
│ │ ├── success ──► typed value │
│ │ │ │
│ │ └── failure ──► error at the boundary │
│ │ │
│ └── typed value flows into the application │
│ │
│ Validation happens once, at the boundary. │
│ │
└──────────────────────────────────────────────────────────┘
Visual: Zod Schema and Type
┌──────────────────────────────────────────────────────────┐
│ const UserSchema = z.object({ │
│ id: z.string().uuid(), │
│ name: z.string().min(1), │
│ email: z.string().email(), │
│ age: z.number().int().min(0).optional(), │
│ }); │
│ │
│ type User = z.infer<typeof UserSchema>; │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ type User = { │ │
│ │ id: string; │ │
│ │ name: string; │ │
│ │ email: string; │ │
│ │ age?: number; │ │
│ │ } │ │
│ └─────────────────────────────────────────┘ │
│ │
│ The schema is the source of truth. │
│ The type follows. │
│ │
└──────────────────────────────────────────────────────────┘
Visual: io-ts Decode Flow
┌──────────────────────────────────────────────────────────┐
│ const result = UserCodec.decode(data); │
│ │ │
│ ▼ │
│ Either<Errors, User> │
│ │ │
│ ├── Left(errors) │
│ │ └── PathReporter.report(result) │
│ │ → human-readable error │
│ │ │
│ └── Right(user) │
│ └── result.right is User │
│ │
│ The caller handles both branches. │
│ │
└──────────────────────────────────────────────────────────┘
Visual: Choosing a Library
┌──────────────────────────────────────────────────────────┐
│ Is the team already using fp-ts? │
│ │ │
│ ├── Yes ──► io-ts │
│ │ Codec model, Either, composition │
│ │ │
│ └── No ──► Zod │
│ Chainable, ergonomic, mainstream │
│ │
│ Both validate data and infer types. │
│ The difference is the mental model. │
│ │
└──────────────────────────────────────────────────────────┘
Visual: Validation in the API Client
┌──────────────────────────────────────────────────────────┐
│ async function fetchUser(id: string): Promise<User> { │
│ const response = await fetch(`/api/users/${id}`); │
│ if (!response.ok) throw new Error(`HTTP ${status}`); │
│ │
│ const data = await response.json(); │
│ │ │
│ │ data is any │
│ ▼ │
│ return UserSchema.parse(data); │
│ │ │
│ │ validated and typed │
│ ▼ │
│ User │
│ } │
│ │
│ The cast is replaced by a check. │
│ │
└──────────────────────────────────────────────────────────┘
Summary
| Item | Zod | io-ts |
|---|---|---|
| Schema syntax | z.object({...}) | t.type({...}) |
| Type extraction | z.infer<T> | t.TypeOf<T> |
| Parsing | .parse, .safeParse | .decode → Either |
| Errors | Structured issues | Errors + PathReporter |
| Refinement | .refine() | t.refinement |
| Discriminated union | z.discriminatedUnion | t.taggedUnion |
| Default choice | ✅ | Functional teams |
| Learning curve | Low | High |
| Ecosystem | Broad | fp-ts |
Key takeaways:
- TypeScript’s types are erased at runtime — a cast is a claim, not a check, and validation is what turns the claim into a guarantee
- A schema is a value that describes a shape and checks data — the type is derived from the schema, so the two cannot drift
- Zod is the default for most projects — chainable API, structured errors, broad ecosystem, and a gentle learning curve
- io-ts is for functional teams — codec model,
Eitherresult, and composition with fp-ts parsethrows andsafeParsereturns a result — the choice depends on whether a failure is a programming error or expected inputdecodereturns anEither—Leftfor errors andRightfor success, handled with the fp-ts patterns- Refinements add custom logic —
refinein Zod,t.refinementin io-ts - Transforms change the value during parsing — normalization, coercion, and conversion belong in the schema
- Discriminated unions narrow the type —
z.discriminatedUnionandt.taggedUnioncheck the discriminant and produce the narrowed union - Validation belongs at the boundary — once, where the untrusted data enters, not repeatedly throughout the application
Remember: Runtime validation is the bridge between the dynamic data of the world and the static types of the compiler. A schema defines the shape once, and both the runtime check and the compile-time type follow from it. Zod and io-ts are two expressions of the same idea, and the choice between them is about the team’s mental model. Validate at the boundary, derive the type from the schema, and the class of bugs that comes from trusting unverified data disappears.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!