trigger.dev icon indicating copy to clipboard operation
trigger.dev copied to clipboard

In v4, task lifecycle hooks break types with "Types of parameters 'payload' and 'payload' are incompatible" error

Open ericallam opened this issue 6 months ago • 1 comments

When using the barebones task function to define a task, and specifying the type of the payload parameter to the run function using a type definition, defining any of the lifecycle hooks before the run function leads to a type error. For example, this works fine:

export const exampleTask = task({
  id: "example-task",
  run: async (payload: { foobar: string }) => {
    console.log("Hello, world from the run function", { payload });
  },
  onStart: async ({ payload }) => {
    console.log(`payload.foobar: ${payload.foobar}`);
  },
});

Adding the 2nd argument to the run function breaks types:

Image

But only when the lifecycle hook is above the run function. Moving it below fixes the types:

export const exampleTask = task({
  id: "example-task",
  run: async (payload: { foobar: string }, { ctx }) => {
    console.log("Hello, world from the run function", { payload });
  },
  onStart: async ({ payload }) => {
    console.log(`payload.foobar: ${payload.foobar}`);
  },
});

ericallam avatar Jun 20 '25 11:06 ericallam

You've identified a TypeScript type inference issue that occurs due to the order of properties in the task definition object. This is a known limitation of how TypeScript infers generic types from object literals. Search files... The issue you're experiencing is a TypeScript generic type inference problem that occurs due to the order of properties in object literals. When TypeScript encounters lifecycle hooks before the run function, it attempts to infer the generic types (TPayload, TOutput, etc.) from the hook signatures, but these hooks have generic parameters that depend on the concrete types from the run function.

Why This Happens

Looking at the type definitions, the TaskOptions type uses multiple generic parameters:

type CommonTaskOptions<
  TIdentifier extends string,
  TPayload = void,
  TOutput = unknown,
  TInitOutput extends InitOutput = any,
> = {
  // ... other properties
  run: (payload: TPayload, params: RunFnParams<TInitOutput>) => Promise<TOutput>;
  onStart?: OnStartHookFunction<TPayload, TInitOutput>;
  // ... other lifecycle hooks
}

The lifecycle hooks like OnStartHookFunction<TPayload, TInitOutput> depend on the same generic types as the run function. When TypeScript processes the object literal:

  1. Hooks first: TypeScript tries to infer TPayload from the hook parameters, but they're generic and don't provide concrete type information
  2. Run function first: TypeScript can infer concrete types from the run function signature, then apply those to the hooks

Solutions

1. Put run function first (Recommended)

This is the simplest solution and follows the logical flow of task definition:

export const exampleTask = task({
  id: "example-task",
  run: async (payload: { foobar: string }, { ctx }) => {
    console.log("Hello, world from the run function", { payload });
  },
  onStart: async ({ payload }) => {
    console.log(`payload.foobar: ${payload.foobar}`);
  },
});

2. Use explicit type annotation

You can also explicitly specify the generic types:

export const exampleTask: Task<"example-task", { foobar: string }> = task({
  id: "example-task",
  onStart: async ({ payload }) => {
    console.log(`payload.foobar: ${payload.foobar}`);
  },
  run: async (payload: { foobar: string }, { ctx }) => {
    console.log("Hello, world from the run function", { payload });
  },
});

3. Use schemaTask for better type safety

For more complex payloads, consider using schemaTask with a schema validation library like Zod:

import { schemaTask } from "@trigger.dev/sdk/v3";
import { z } from "zod";

export const exampleTask = schemaTask({
  id: "example-task",
  schema: z.object({
    foobar: z.string(),
  }),
  onStart: async ({ payload }) => {
    console.log(`payload.foobar: ${payload.foobar}`); // Fully typed
  },
  run: async (payload, { ctx }) => {
    console.log("Hello, world from the run function", { payload });
  },
});

Best Practice Recommendation

Always put the run function first in your task definitions. This:

  • Ensures proper type inference
  • Makes the code more readable (the main logic comes first)
  • Follows the pattern used in the official documentation and examples
  • Avoids TypeScript inference issues

This is a limitation of TypeScript's type inference system rather than a bug in Trigger.dev, and putting the run function first is the recommended pattern throughout the codebase.

ericallam avatar Jun 20 '25 11:06 ericallam