I have a queries file with a getExplanationBySlug query. Sometimes, TS seems to perfectly know what, inside an action, ctx.runQuery(internal.queries.getExplanationBySlug) will return, and seemingly the next moment, it has forgotten and just sees the return type as "any". That any trhen causes type errors in my actions file, and that stops convex codegen from working, so it seemingly also can't repair the generated types. At least, I assume that's what's happening. How do I get types toà always keep working
#TS types of queries and actions
10 messages · Page 1 of 1 (latest)
Hi @shrewd gate. I'm kapa.ai, your friendly support bot. I will try to answer your question by searching through the knowledge base. :wave:
This is (unfortunately) a known TypeScript limitation with Convex’s deep, generated types: you’re running into circular type inference around ctx.runQuery / ctx.runMutation / ctx.runAction. The symptoms you describe (sometimes fine, sometimes “any”, codegen then breaking) match exactly what’s documented and discussed in the threads. [Circular inference]
The fix is to break the inference loop explicitly in the calling function. There are two supported patterns:
1. Type the action’s handler return type
export const myAction = action({
args: { slug: v.string() },
handler: async (ctx, args): Promise<YourReturnType> => {
const explanation = await ctx.runQuery(
internal.queries.getExplanationBySlug,
{ slug: args.slug }
);
// ...use explanation...
return {
explanation,
// ...
};
},
});
Here YourReturnType is a normal TS type you define (object, union, etc.). This stops TS from trying to infer the action’s return type through runQuery, which is what causes the “implicitly has type any / excessively deep” errors. [Circular inference]
2. Type the result of ctx.runQuery explicitly
export const myAction = action({
args: { slug: v.string() },
handler: async (ctx, args) => {
const explanation: ExpectedExplanationType =
await ctx.runQuery(internal.queries.getExplanationBySlug, {
slug: args.slug,
});
return {
explanation,
};
},
});
Again, ExpectedExplanationType is a plain TS type you write out. TS will still check that this matches what getExplanationBySlug actually returns, so you don’t lose type safety. [Circular inference]
Things that don’t fix it
From the docs and Discord threads:
- Adding
returns: v.*to the query or action alone does not break the circular inference if the caller still relies on inference fromctx.run*. [Deep instantiation] - Using
Doc<>,Id<>, or other generated types inside thectx.run*result type can still participate in the cycle if you don’t explicitly annotate somewhere. [Deep instantiation] - The error can appear in “random” files because a single problematic
ctx.run*elsewhere poisons the TS server. You often have to search for allctx.runQuery/runMutation/runActionand fix each one that returns (or passes through) the result. [Deep instantiation; Tools thread]
Practical workflow
- Search your Convex code for
ctx.runQuery,ctx.runMutation,ctx.runAction. - For each one where the result is:
- returned directly, or
- included in the returned object,
add either: - an explicit handler return type (
handler: async (…) : Promise<…> => { … }), or - an explicit type on the
const result: Type = await ctx.runQuery(...).
- Re-run
npx convex devso_generatedis rebuilt once TS is happy.
Once you do this consistently, the “sometimes it knows, sometimes it’s any” behavior should stop, and codegen will be stable again. [Circular inference]
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
@shrewd gate Kapa.ai is still learning and improving, please let me know how I did by reacting below.
@median solstice can I call an internal action from within an external action, but not await its result? Or should I then use the scheduler instead to just schedule it to run immediately?
Let me check the knowledge base for you again. One moment! :books:
You can do both, but they’re not equivalent, and the docs strongly steer you toward using the scheduler for this pattern.
1. ctx.runAction without await
From an action you can call another action via ctx.runAction and simply not await it:
export const outer = action({
args: {},
handler: async (ctx) => {
void ctx.runAction(internal.foo.bar, { ... });
// outer action returns immediately
},
});
This is allowed (you’re already in an action), but:
- It still spins up a separate Convex function call with its own overhead. [Best practices]
- It’s not tied to any transaction outcome (you’re in an action, not a mutation), so you don’t get atomicity guarantees.
Docs recommend using runAction mainly when you need to cross runtimes (Convex → Node.js), not as a general “fire-and-forget” mechanism. [Best practices]
2. Using the scheduler (recommended)
The more idiomatic Convex way to “kick something off and not wait” is to schedule the internal action:
await ctx.scheduler.runAfter(0, internal.foo.bar, { ... });
Key properties:
- It’s explicitly a scheduled function, stored durably in Convex’s
_scheduled_functionstable. [Scheduler; Scheduled functions] - From a mutation, scheduling is atomic with the rest of the mutation: if the mutation fails, nothing is scheduled; if it commits, the action is guaranteed to be scheduled. [Scheduled functions; Pinecone example]
- Using
runAfter(0, ...)is the documented pattern for “run immediately after this function completes.” [Scheduled functions]
Which should you use?
- If you’re in a mutation and want to trigger an action: you must use the scheduler. [Tutorial example; Pinecone example]
- If you’re in an action and just want “fire-and-forget” behavior:
- You can use
ctx.runActionwithoutawait, but - Using
ctx.scheduler.runAfter(0, internal.someAction, args)is clearer, durable, and matches Convex’s recommended patterns for background work. [Scheduled functions]
- You can use