
Convex Return Validators
- 4 installs
- 627 repo stars
- Updated May 20, 2026
- waynesutton/markdown-site
Guides when to use and when to skip return validators in Convex functions, favoring TypeScript inference by default and returns validators for exact runtime contracts.
About
This skill explains Convex's updated guidance on return validators, favoring TypeScript inference by default and using returns only when an exact runtime contract is needed. A developer uses it when writing Convex queries, mutations, or actions and deciding on return value validation or type safety.
- Prefers TypeScript types over always adding a returns validator
- Uses returns validators only for enforced exact runtime contracts
Convex Return Validators by the numbers
- 4 all-time installs (skills.sh)
- +2 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #3,711 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/waynesutton/markdown-site --skill convex-return-validatorsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| repo stars | ★ 627 |
| Last updated | May 20, 2026 |
| Repository | waynesutton/markdown-site ↗ |
What it does
Guides when to use and when to skip return validators in Convex functions, favoring TypeScript inference by default and returns validators for exact runtime contracts.
Files
When to and when not to use return validators in Convex
Convex recently updated its guidance on return validators. The old rule was "always add a returns validator." The new guidance is: prefer simple TypeScript types and inference by default. Use `returns:` when you actually want Convex to enforce an exact runtime contract.
Return validators aren't bad. The word "always" was doing damage.
What is a return validator?
Convex lets you validate arguments coming into a function using args and return values going out using returns. A return validator declares the return shape, and Convex checks it at runtime.
import { query } from "./_generated/server";
import { v } from "convex/values";
export const getUserPreview = query({
args: { userId: v.id("users") },
returns: v.object({
name: v.string(),
}),
handler: async (ctx, { userId }) => {
const user = await ctx.db.get(userId);
if (!user) throw new Error("User not found");
return { name: user.name };
},
});If the returned value doesn't match, you get a runtime error instead of silently returning unexpected data. Object validators don't allow extra properties — returning extra fields will fail validation at runtime.
Why the old "always" rule existed
The original motivation was more about TypeScript pain than runtime correctness. Convex projects can hit circular type problems because functions reference generated api or internal objects, and those references become part of the generated types. Types reference types reference types until TypeScript gives up.
The thinking: if the model always declared return validators, it would reduce reliance on inferred return types and break the cycle. In practice, it only helps in specific circumstances.
Why "always" causes problems
In real codebases, and especially in agentic AI workflows, the "always" rule creates predictable failure modes:
Verbosity and copy-paste fragility
LLMs don't reuse validators. They copy-paste shapes inline. You end up with return validators like this on every function:
export const listByProject = query({
args: { projectId: v.id("projects") },
returns: v.array(
v.object({
_id: v.id("activityLog"),
_creationTime: v.number(),
action: v.string(),
userId: v.id("users"),
userName: v.string(),
projectId: v.id("projects"),
entityType: v.string(),
entityId: v.string(),
metadata: v.optional(v.string()),
}),
),
handler: async (ctx, args) => {
// ...
},
});It works, but once that shape is copy-pasted across multiple functions, a schema change stops being a "change one place" job. You update a field, chase compile errors, chase runtime validation errors, then update a bunch of validators that are almost-the-same-but-not-quite.
Token inefficiency for AI
Every extra hundred tokens matters when the model is trying to keep the codebase in working memory and plan multi-step changes. Verbosity translates into slower iterations and more "oops, I forgot a field" cycles.
Hallucination risk
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 catches a lot of this, but catching things later is still slower than not introducing the problem.
System field duplication
Unless you're using helper utilities, return validators drag you into re-declaring _id and _creationTime over and over. If you want heavy validator usage, look at the validator utilities in convex-helpers.
Convex already provides ergonomic type helpers like Doc<> and WithoutSystemFields, so a lot of the time you can keep code tidier by leaning on normal TypeScript types and inference.
The "exact type" problem — where return validators shine
TypeScript is structurally typed, which means it doesn't have true exact types. A function can claim it returns a User but still accidentally return extra fields.
This becomes more likely once any gets involved, or when consuming untyped external API data:
// WITHOUT return validator — extra field leaks silently
export const getUser = query({
args: {},
handler: async (ctx): Promise<User> => {
return {
id: "123",
name: "Alice",
email: "alice@example.com", // Extra field — no error!
} as any;
},
});
// WITH return validator — Convex catches extra field at runtime
export const getUser = query({
args: {},
returns: v.object({
id: v.string(),
name: v.string(),
}),
handler: async (ctx) => {
return {
id: "123",
name: "Alice",
email: "alice@example.com", // Runtime error!
} as any;
},
});That guarantee is real and valuable. It's just not needed everywhere, and using it everywhere comes with costs.
When you SHOULD use return validators
Return validators are useful when you need runtime enforcement of an exact contract, not just TypeScript typechecking.
Components codegen
There are cases where inference isn't available and the validator becomes the contract.
Static codegen workflows
With static codegen, functions don't have return type inference and will default to v.any() if they don't have a returns validator.
OpenAPI generation
You often want the server to enforce the contract you're generating client types from. Missing validators get treated as any, which makes the resulting spec less useful.
When any or unvalidated external data is involved
If there's a realistic chance you'll accidentally return data you didn't intend to expose, return validators catch that. For external API calls, it's usually better to validate the data at the boundary (inside an action right after the fetch). But belt-and-braces is fair too.
When you should NOT use return validators
Standard queries and mutations with good TypeScript types
If your handler's return type is already well-typed via inference or explicit TypeScript annotations, the return validator adds verbosity without meaningful safety.
AI-generated code in agentic workflows
This is counterintuitive, but the "always" rule was actively harming AI code quality. LLMs produce better Convex code when they can lean on TypeScript inference instead of reproducing schema shapes as validators. Fewer tokens, fewer hallucinations, faster iteration.
Rapid prototyping
When the return shape is still changing, return validators slow you down. Add them once the shape stabilizes and you need the runtime contract.
Internal functions
Functions using internalQuery, internalMutation, or internalAction aren't exposed to clients. TypeScript inference is usually sufficient.
Updated guidance for AI rules and prompts
If you're writing Convex AI rules (for Claude, Cursor, Copilot, or any agentic tool), update the guidance:
Old rule: "Always add a returns validator to queries and mutations."
New rule: "Prefer simple TypeScript types and inference by default. Use returns: when you actually want Convex to enforce an exact runtime contract — such as components codegen, static codegen, OpenAPI generation, or when handling any/unvalidated external data."
When AI does use return validators, encourage it to:
- Reuse shared validators from a central file instead of copy-pasting shapes inline
- Use
.pick(),.omit(),.extend()on object validators to derive return types - Use
Doc<"tableName">andWithoutSystemFieldsfor TypeScript types when validators aren't needed - Use validator utilities from
convex-helpersto reduce system field duplication
Decision framework
| Scenario | Use returns:? | Why |
|---|---|---|
| Components codegen | Yes | Inference not available, validator is the contract |
| Static codegen | Yes | Functions default to v.any() without it |
| OpenAPI generation | Yes | Missing validators become any in the spec |
any or unvalidated external data | Yes | Catches accidental data leakage at runtime |
| Standard queries with good TS types | No | TypeScript inference is sufficient |
| AI/LLM-generated code (default) | No | Reduces verbosity, tokens, and hallucination risk |
| Internal functions | No | Not client-facing, inference is fine |
| Rapid prototyping | No | Add later when shape stabilizes |
Further reading
- Original blog post: https://stack.convex.dev/when-to-and-when-not-to-use-return-validators
- Convex validation docs: https://docs.convex.dev/functions/validation
- Convex TypeScript docs (Doc<>, WithoutSystemFields): https://docs.convex.dev/generated-api/server#doc
- Static codegen docs: https://docs.convex.dev/production/best-practices/static-codegen
- OpenAPI docs: https://docs.convex.dev/http-api/openapi
- convex-helpers validator utilities: https://github.com/get-convex/convex-helpers
Updated Convex AI rules for return validators
What changed
The Convex team removed the "always add return validators" guidance from their AI rules. If you have Convex AI rules or system prompts that tell LLMs to always add returns: validators, update them.
Why the old rule hurt AI code quality
Three failure modes kept showing up in agentic workflows:
1. Copy-paste verbosity. LLMs don't create shared validator files. They inline the full shape on every function. A 10-field return object copy-pasted across 8 functions is 80 lines of validators that all need to stay in sync.
2. Token bloat. Every validator the AI reads and writes burns tokens. In agentic workflows where the model re-reads files to plan multi-step changes, those extra tokens compound. Slower iterations, more mistakes.
3. Hallucinated fields. Asking a model to reproduce a schema as a validator is asking it to recall and rewrite a data shape from memory. It will invent fields, miss fields, or use wrong types. TypeScript catches some of this, but the damage (wasted cycles) is already done.
Updated AI rules template
Replace the old "always add returns" rule with this:
## Return validators
Use simple TypeScript types and inference by default.
Do NOT add a `returns:` validator unless one of these applies:
- The function is part of a Convex component (inference not available)
- You're using static codegen (functions default to v.any() without returns)
- You're generating an OpenAPI spec (missing validators become any)
- The handler deals with `any`, type assertions, or unvalidated external data
- You explicitly want Convex to enforce an exact runtime contract
When you do use return validators:
- Define shared validators in a central file and import them
- Use .pick(), .omit(), .extend() to derive return shapes from base validators
- Use convex-helpers validator utilities to avoid re-declaring system fields
- Never copy-paste inline validator shapes across multiple functions
When you don't use return validators:
- Use Doc<"tableName"> for full document return types
- Use WithoutSystemFields for insert shapes
- Let TypeScript inference handle the return typeExamples for AI prompts
Prompt that produces good code (no return validator needed)
Write a Convex query that fetches all tasks for a user, sorted by creation time.
Use TypeScript inference for the return type — no returns validator needed.Prompt that should use a return validator
Write a Convex query that calls an external API and returns a subset of the response.
Since we're dealing with unvalidated external data, add a returns validator
to enforce the exact shape.Prompt for a component function
Write a Convex component query for a rate limiter.
Since this is a component (no inference available), add a returns validator
as the type contract.Convex Return Validators Quick Reference
The updated rule
Old: Always add a returns validator. New: Prefer TypeScript types and inference by default. Use returns: when you need runtime enforcement of an exact contract.
Syntax
import { query, mutation, action } from "./_generated/server";
import { v } from "convex/values";
export const myFunction = query({
args: { /* argument validators */ },
returns: /* return validator here */,
handler: async (ctx, args) => {
// your logic
},
});Common return validator patterns
Return a single value
returns: v.string()
returns: v.number()
returns: v.boolean()
returns: v.null()
returns: v.id("tableName")Return an object
returns: v.object({
name: v.string(),
count: v.number(),
active: v.boolean(),
})Return an array
returns: v.array(v.object({
_id: v.id("tasks"),
title: v.string(),
}))Return a union (multiple possible shapes)
returns: v.union(
v.object({ type: v.literal("success"), data: v.string() }),
v.object({ type: v.literal("error"), message: v.string() })
)Return nullable
returns: v.nullable(v.object({
name: v.string(),
}))
// equivalent to v.union(v.object({...}), v.null())Validator composition (reduces duplication)
const base = v.object({
name: v.string(),
email: v.string(),
secret: v.string(),
});
base.pick("name", "email") // only name + email
base.omit("secret") // everything except secret
base.partial() // all fields become optional
base.extend({ age: v.number() }) // add new fieldsTypeScript alternatives (use these by default)
import { Doc } from "./_generated/dataModel";
// Use Doc<> for full document types
type User = Doc<"users">;
// Use WithoutSystemFields for insert/update shapes
import { WithoutSystemFields } from "convex/server";
type NewUser = WithoutSystemFields<Doc<"users">>;What return validators enforce at runtime
1. Exact object shapes (extra fields cause errors) 2. Correct types (string vs number, etc.) 3. Required vs optional fields 4. Valid union discriminants 5. No undefined values (not valid in Convex)
What return validators DON'T help with
1. TypeScript inference (already works without them) 2. Business logic correctness 3. Authorization (your handler's job) 4. Performance