Every time your API receives a request, you validate it. Every time a function receives an argument from an external system, you check it. Do this manually, and a single endpoint can accumulate more validation code than business logic.
This is the hidden tax of runtime contracts. You need them because the type system lies: JSON over HTTP, database fields, and environment variables all arrive as untyped data at runtime. But implementing them with hand-rolled if statements turns simple functions into unreadable walls of defensive code.
There is a better way.
What runtime contracts actually are
A runtime contract is a guarantee that a function makes about its inputs and outputs. If you pass valid data in, you get valid data out. If you violate the contract, the function fails fast with a clear error.
This is Design by Contract, and it has been around since Eiffel. The idea is not new. What is new is the tooling that lets you express contracts without writing a validator for every field.
The core insight: contracts are schemas, not code. A schema describes shape, constraints, and relationships. Code that checks those things manually is just a schema written in the wrong language.
The boilerplate trap
Here is what manual validation looks like in practice. You have a function that processes a user signup request:
function createUser(payload: unknown) {
if (!payload || typeof payload !== "object") {
throw new Error("Invalid payload");
}
if (!("email" in payload) || typeof (payload as any).email !== "string") {
throw new Error("email is required and must be a string");
}
if (!(payload as any).email.includes("@")) {
throw new Error("email must be valid");
}
if (!("age" in payload) || typeof (payload as any).age !== "number") {
throw new Error("age is required and must be a number");
}
if ((payload as any).age < 13 || !Number.isInteger((payload as any).age)) {
throw new Error("age must be an integer of at least 13");
}
if (
"tags" in payload &&
(!Array.isArray((payload as any).tags) ||
!(payload as any).tags.every((t: any) => typeof t === "string"))
) {
throw new Error("tags must be an array of strings");
}
// ... finally, the actual work
}
This is exhausting, error-prone, and incomplete. Did you check that email matches a real email format? That age is not NaN? That tags elements are non-empty strings? Probably not, because you got tired.
And this is just one function. Multiply by every boundary in your system.
How declarative schemas replace boilerplate
Instead of writing validators, describe the shape and let a library enforce it.
import { z } from "zod";
const UserSchema = z.object({
email: z.string().email(),
age: z.number().int().min(13),
tags: z.array(z.string().min(1)).optional(),
});
function createUser(payload: unknown) {
const user = UserSchema.parse(payload);
// user is typed as { email: string; age: number; tags?: string[] }
// actual business logic goes here
}
The schema is the documentation. It is also the validator, the type definition, and the error message generator. Change the schema in one place, and the contract updates everywhere it is used.
This is not magic. The library walks the schema at runtime and applies the checks. The difference is that the checks are composed from small, reusable primitives rather than written out by hand each time.
Contracts at function boundaries
The real power comes from attaching schemas directly to function inputs and outputs. You want the contract to live at the boundary, not leak into the implementation.
function withContract<TInput, TOutput>(
inputSchema: z.ZodSchema<TInput>,
outputSchema: z.ZodSchema<TOutput>,
handler: (input: TInput) => TOutput
) {
return (rawInput: unknown): TOutput => {
const input = inputSchema.parse(rawInput);
const output = handler(input);
return outputSchema.parse(output);
};
}
const UserResponseSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
});
const createUser = withContract(
UserSchema,
UserResponseSchema,
(user) => {
// zero validation code here
return db.users.create(user);
}
);
Now the function body contains zero validation code. The contract is enforced at the edge, and the compiler still knows the types inside because the schema infers them.
Where this breaks down: performance, inference, and over-validation
Declarative schemas are not free. You should know the trade-offs before you adopt them.
Performance is the obvious concern. Parsing a complex schema is slower than a few if checks. In practice, the difference is negligible for most APIs. Zod is roughly 10× slower than hand-rolled checks for simple objects. For most HTTP handlers, that is microseconds of difference. If you are validating millions of records in a hot loop, benchmark first.
Type inference gaps can also bite you. Inferred types from schemas are sometimes stricter or looser than what you would write by hand. You may need to define custom types and tell the schema to match them, rather than the other way around.
Over-validation is the silent killer. Not every function needs a runtime contract. Internal functions that receive data already validated at the API boundary do not need to be re-checked. Apply contracts at system boundaries, not at every helper function.
How to implement this without rewriting your codebase
You do not need a big-bang migration. Start with your API entry points.
- Pick one external-facing function or route handler.
- Write a schema for its input.
- Replace manual validation with schema parsing.
- Let the schema infer the TypeScript type. Delete your hand-written interface.
Do this for one endpoint, then one service, then one domain. Within a week, you will notice your test suites shrinking because you no longer need to test validation edge cases that the schema already covers.
If you are not using TypeScript, the pattern translates. Python has Pydantic. Go has go-playground/validator. Rust has validator. The principle is the same: describe the contract, generate the checks.
FAQ
Do runtime contracts replace unit tests?
No. They replace the subset of unit tests that verify input validation. You still test business logic, error handling, and integration behavior.
What about custom validation rules?
Every serious schema library supports custom refinements. Use them sparingly. If your custom rule is longer than five lines, it probably belongs in the function body, not the schema.
Should I validate on the way out, too?
Yes, for critical functions. An output contract catches bugs where your business logic produces malformed data. It is cheaper to fail at the source than to let bad data propagate to another service.
Bottom line
Runtime contracts are non-negotiable for systems that touch untyped data. The only choice is whether you pay for them with boilerplate or with a schema library.
The schema library costs you a dependency and a small performance overhead. Boilerplate costs you maintainability, correctness, and your sanity.
Pick the dependency.