
When to and when not to use return validators
Up until very recently, we’ve been recommending that LLMs (via the Convex AI rules) should always add a returns validator to Convex queries and mutations. If you’ve been following that advice, dont worry, you didn’t do anything wrong but we’re changing the guidance. Not because return validators are bad, but because the word “always” is doing damage here.
What is a return validator?
Convex lets you validate arguments coming in to a function using args and return values coming out using returns (more detail in the validation docs). A return validator is exactly what it sounds like: you declare the return shape, and Convex checks it at runtime.
Here’s a tiny example:
1import { query } from "./_generated/server";
2import { v } from "convex/values";
3
4export const getUserPreview = query({
5 args: { userId: v.id("users") },
6 returns: v.object({ // <----- specifies the runtime and compile time type shape
7 name: v.string(),
8 }),
9 handler: async (ctx, { userId }) => {
10 const user = await ctx.db.get(userId);
11 if (!user) throw new Error("User not found");
12 return { name: user.name };
13 },
14});
15If the returned value doesn’t match, you get a runtime error instead of silently returning unexpected data. The key word is exactly: object validators don’t allow extra properties, so returning extra fields will fail validation at runtime (docs), more on why thats important below.
Why did we tell LLMs to always add them?
Our original motivation was more about TypeScript pain than runtime correctness. At the time, we were dealing with circular type issues in some codegen-heavy setups.
Convex projects can hit circular type problems because you write functions that reference generated api or internal objects, and those references become part of the generated types. Then you can end up with types referencing types referencing types until TypeScript gives up.
The thinking was that if the model always declared return validators, it would reduce reliance on inferred return types and maybe break the cycle. That was the theory, but in practice it only helps in specific circumstances.
(BTW, if Convex ever moved to a more fluent function definition API like the one in fluent-convex, return validators might play nicer with circular type issues. But that’s a separate conversation.)
Why remove the “always” rule?
Because in real codebases, and especially in agentic workflows, that rule creates a few very predictable failure modes. First up is verbosity, and it gets bad fast.
The common pattern is that the model doesn’t reuse validators, it copy-pastes shapes inline. So you end up with return validators like this:
1export const listByProject = query({
2 args: { projectId: v.id("projects") },
3 returns: v.array(
4 v.object({
5 _id: v.id("activityLog"),
6 _creationTime: v.number(),
7 action: v.string(),
8 userId: v.id("users"),
9 userName: v.string(),
10 projectId: v.id("projects"),
11 entityType: v.string(),
12 entityId: v.string(),
13 metadata: v.optional(v.string()),
14 }),
15 ),
16 handler: async (ctx, args) => {
17 // ...
18 },
19});
20It works, but it’s kinda ugly and fragile. Once your return shape is copy-pasted into multiple functions, a schema change stops being a “change one place” kinda job.
It becomes a bit of a chore. You have to update a field, then chase compile errors, then chase runtime validation errors, and then update a bunch of validators that are all almost-the-same-but-not-quite. That’s not the sort of “type safety” anyone enjoys.
The second issue is that it’s token-inefficient in a way that really matters for AI. If you’re using an agentic workflow, the model is reading and re-reading those validators all the time.
Every extra hundred tokens matters when the model is trying to keep the codebase in working memory and plan multi-step changes. Verbosity almost always translates into slower iterations and more “oops, I forgot a field” cycles.
The third issue is hallucinations. Asking a model to reproduce a schema as a validator increases the chance it invents fields, misses fields, or picks the wrong validator type.
TypeScript will catch a lot of this, but catching things later is still slower than just not introducing the problem in the first place.
Finally, unless you’re using helper utilities, return validators often drag you into re-declaring system fields like _id and _creationTime again and again. If you do want to go heavy on validators, it’s worth looking at the validator utilities in convex-helpers (they exist for exactly this kind of duplication pain).
Also, Convex already gives you ergonomic type helpers like Doc<> and WithoutSystemFields (TypeScript docs), so a lot of the time you can keep the code tidier by leaning on normal TypeScript types and inference.
The “exact type” problem
TypeScript is structurally typed, which means it doesn’t have true exact types. You can write a function that claims it returns a User, but still accidentally return extra fields.
This becomes more likely once any gets involved, or once you’re consuming data from some untyped external API. Here’s a simplified example:
1import { query } from "./_generated/server";
2
3type User = {
4 id: string;
5 name: string;
6};
7
8export const getUser = query({
9 args: {},
10 handler: async (ctx): Promise<User> => {
11 return {
12 id: "123",
13 name: "Alice",
14 email: "alice@example.com", // Extra field
15 } as any;
16 },
17});
18With a return validator, Convex enforces exactness at runtime. If you return extra fields, you get a runtime error instead of accidentally leaking data. (Ask me how I know.)
1import { query } from "./_generated/server";
2import { v } from "convex/values";
3
4export const getUser = query({
5 args: {},
6 returns: v.object({
7 id: v.string(),
8 name: v.string(),
9 }),
10 handler: async (ctx) => {
11 return {
12 id: "123",
13 name: "Alice",
14 email: "alice@example.com", // runtime error
15 } as any;
16 },
17});
18
That guarantee is real, and it’s valuable. It’s just not needed everywhere, and using it everywhere comes with costs.
So when should you use return validators?
So return validators are still useful when you need runtime enforcement of an exact contract, not just TypeScript typechecking.
There are a few places where they’re genuinely the right tool. Components codegen is a good example, because there are cases where inference isn’t available and the validator becomes the contract.
Static codegen workflows can also be a good fit if you’re aiming for the tightest, most explicit interface. There’s also a practical reason here: with static codegen, functions don’t have return type inference and will default to v.any() if they don’t have a returns validator (static codegen docs).
OpenAPI generation is another example, since you often want the server to enforce the contract you’re generating client types from. Validators aren’t required, but missing validators get treated as any, which makes the resulting spec a lot less useful (OpenAPI docs).
Any time any or unvalidated external data is involved, return validators are great for catching errors. If there’s a realistic chance you’ll accidentally return data you didn’t intend to expose, they’re worth it.
For external API calls, it’s usually better to validate the external data at the boundary (for example inside an action right after the fetch). But if you want belt-and-braces, it’s totally fair to enforce exactness on the return too.
Conclusion
Return validators are a valuable tool that solve a real problem. But the “always add them” rule creates more problems than it solves: verbosity, refactor pain, token bloat, and a higher chance of the LLM getting the schema slightly wrong.
So we’re moving to a more nuanced guideline: prefer simple TypeScript types and inference by default. Use returns: when you actually want Convex to enforce an exact runtime contract.
Convex is the backend platform with everything you need to build your full-stack AI project. Cloud functions, a database, file storage, scheduling, workflow, vector search, and realtime updates fit together seamlessly.