#TS types of queries and actions

10 messages · Page 1 of 1 (latest)

shrewd gate
#

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

median solsticeBOT
#

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 from ctx.run*. [Deep instantiation]
  • Using Doc<>, Id<>, or other generated types inside the ctx.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 all ctx.runQuery / runMutation / runAction and fix each one that returns (or passes through) the result. [Deep instantiation; Tools thread]

Practical workflow

  1. Search your Convex code for ctx.runQuery, ctx.runMutation, ctx.runAction.
  2. 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(...).
  3. Re-run npx convex dev so _generated is 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.

shrewd gate
#

@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?

median solsticeBOT
#

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_functions table. [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.runAction without await, but
    • Using ctx.scheduler.runAfter(0, internal.someAction, args) is clearer, durable, and matches Convex’s recommended patterns for background work. [Scheduled functions]

So: yes, you technically can call an internal action from an action and not await it, but for a background / non-awaited task, especially anything you might later want to schedule from a mutation too, prefer the scheduler with runAfter(0, ...).

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon: