From 20fe031f086d74c4dba750b9143e4e62e670f9f0 Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Mon, 16 Mar 2026 14:59:09 -0700 Subject: [PATCH 1/3] fix(nexus): infer correct output type in WorkflowRunOperationHandler from typed workflows startWorkflow now returns WorkflowHandle> instead of WorkflowHandle, so the handler's output type is inferred as the workflow's return type (e.g. string) rather than the workflow function type. A new workflowResultType brand on WorkflowHandle makes WorkflowHandle structurally distinct from WorkflowHandle, enabling TypeScript to catch type mismatches when explicit type params contradict the actual workflow. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/nexus/src/workflow-helpers.ts | 29 +++++++-- packages/test/src/test-nexus-handler.ts | 84 +++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/packages/nexus/src/workflow-helpers.ts b/packages/nexus/src/workflow-helpers.ts index 8e7712e4f..9235d5616 100644 --- a/packages/nexus/src/workflow-helpers.ts +++ b/packages/nexus/src/workflow-helpers.ts @@ -1,5 +1,5 @@ import * as nexus from 'nexus-rpc'; -import { Workflow } from '@temporalio/common'; +import { Workflow, WorkflowResultType } from '@temporalio/common'; import { Replace } from '@temporalio/common/lib/type-helpers'; import { WorkflowStartOptions as ClientWorkflowStartOptions } from '@temporalio/client'; import { type temporal } from '@temporalio/proto'; @@ -9,14 +9,19 @@ import { convertNexusLinkToWorkflowEventLink, convertWorkflowEventLinkToNexusLin import { getClient, getHandlerContext, log } from './context'; declare const isNexusWorkflowHandle: unique symbol; +declare const workflowResultType: unique symbol; /** * A handle to a running workflow that is returned by the {@link startWorkflow} helper. * This handle should be returned by {@link WorkflowRunOperationStartHandler} implementations. * + * The type parameter `T` carries the workflow's result type for downstream type inference in + * {@link WorkflowRunOperationHandler}. It is encoded in the {@link workflowResultType} brand so + * that `WorkflowHandle` and `WorkflowHandle` are structurally distinct. + * * @experimental Nexus support in Temporal SDK is experimental. */ -export interface WorkflowHandle<_T> { +export interface WorkflowHandle { readonly workflowId: string; readonly runId: string; @@ -31,6 +36,17 @@ export interface WorkflowHandle<_T> { * @experimental Nexus support in Temporal SDK is experimental. */ readonly [isNexusWorkflowHandle]: typeof isNexusWorkflowHandle; + + /** + * Type brand that carries the workflow's result type, making `WorkflowHandle` structurally + * distinct from `WorkflowHandle` so TypeScript can catch type mismatches. + * + * @internal + * @hidden + * + * @experimental Nexus support in Temporal SDK is experimental. + */ + readonly [workflowResultType]: T; } /** @@ -54,7 +70,7 @@ export async function startWorkflow( ctx: nexus.StartOperationContext, workflowTypeOrFunc: string | T, workflowOptions: WorkflowStartOptions -): Promise> { +): Promise>> { const { client, taskQueue } = getHandlerContext(); const links = Array(); if (ctx.inboundLinks?.length > 0) { @@ -107,7 +123,7 @@ export async function startWorkflow( return { workflowId: handle.workflowId, runId: handle.firstExecutionRunId, - } as WorkflowHandle; + } as WorkflowHandle>; } /** @@ -123,6 +139,11 @@ export type WorkflowRunOperationStartHandler = ( /** * A Nexus Operation implementation that is backed by a Workflow run. * + * The type parameter `O` represents the operation's output type. When the handler uses + * {@link startWorkflow} with a typed workflow function, `O` is inferred as the workflow's + * return type (e.g. `string`), since {@link startWorkflow} returns + * `WorkflowHandle>`. + * * @experimental Nexus support in Temporal SDK is experimental. */ export class WorkflowRunOperationHandler implements nexus.OperationHandler { diff --git a/packages/test/src/test-nexus-handler.ts b/packages/test/src/test-nexus-handler.ts index 3ce174a5a..5fb79e99f 100644 --- a/packages/test/src/test-nexus-handler.ts +++ b/packages/test/src/test-nexus-handler.ts @@ -795,6 +795,90 @@ test('WorkflowRunOperationHandler does not accept WorkflowHandle from WorkflowCl t.pass(); }); +export async function echoWorkflow(input: string): Promise { + return input; +} + +test('WorkflowRunOperationHandler infers correct output type from typed workflow function', async (t) => { + // When constructing WorkflowRunOperationHandler without explicit type parameters using a typed + // workflow function, the operation output type should be inferred as the workflow's return type + // (e.g. string), not the workflow function type (e.g. (input: string) => Promise). + const _stringOp: nexus.OperationHandler = new temporalnexus.WorkflowRunOperationHandler( + async (ctx, input: string) => { + return await temporalnexus.startWorkflow(ctx, echoWorkflow, { + args: [input], + workflowId: 'test', + }); + } + ); + + // @ts-expect-error - Output type should be string, not number + const _mismatchedOp: nexus.OperationHandler = new temporalnexus.WorkflowRunOperationHandler( + async (ctx, input: string) => { + return await temporalnexus.startWorkflow(ctx, echoWorkflow, { + args: [input], + workflowId: 'test', + }); + } + ); + + // Explicit type parameters should also work correctly. + const _explicitStringOp: nexus.OperationHandler = new temporalnexus.WorkflowRunOperationHandler< + string, + string + >(async (ctx, input) => { + return await temporalnexus.startWorkflow(ctx, echoWorkflow, { + args: [input], + workflowId: 'test', + }); + }); + + // @ts-expect-error - Explicit output type string is not assignable to number + const _explicitMismatchedOp: nexus.OperationHandler = new temporalnexus.WorkflowRunOperationHandler< + string, + string + >(async (ctx, input) => { + return await temporalnexus.startWorkflow(ctx, echoWorkflow, { + args: [input], + workflowId: 'test', + }); + }); + + const _explicitContradictsWorkflow: nexus.OperationHandler = + // @ts-expect-error - Explicit type params contradict echoWorkflow which returns string + new temporalnexus.WorkflowRunOperationHandler(async (ctx, input) => { + return await temporalnexus.startWorkflow(ctx, echoWorkflow, { + args: [input], + workflowId: 'test', + }); + }); + + // When a string workflow name is used, T infers as Workflow (the base type), so + // WorkflowResultType resolves to `any`. This means the handler is assignable to any + // output type and TypeScript cannot catch mismatches. + const _stringNameOp: nexus.OperationHandler = new temporalnexus.WorkflowRunOperationHandler( + async (ctx, input: string) => { + return await temporalnexus.startWorkflow(ctx, 'some-workflow', { + args: [input], + workflowId: 'test', + }); + } + ); + + // This is NOT caught — string workflow names lose type safety on the output type. + const _stringNameAnyOutput: nexus.OperationHandler = new temporalnexus.WorkflowRunOperationHandler( + async (ctx, input: string) => { + return await temporalnexus.startWorkflow(ctx, 'some-workflow', { + args: [input], + workflowId: 'test', + }); + } + ); + + // This test only checks for compile-time errors. + t.pass(); +}); + test('createNexusEndpoint and deleteNexusEndpoint', async (t) => { const { env } = t.context; const taskQueue = 'test-delete-endpoint-' + randomUUID(); From f02e8aafdb6160d17efec5d594522e1d939076c9 Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Tue, 17 Mar 2026 10:56:22 -0700 Subject: [PATCH 2/3] Remove some comments based on PR feedback --- packages/nexus/src/workflow-helpers.ts | 7 +------ packages/test/src/test-nexus-handler.ts | 1 - 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/nexus/src/workflow-helpers.ts b/packages/nexus/src/workflow-helpers.ts index 9235d5616..9e4e568be 100644 --- a/packages/nexus/src/workflow-helpers.ts +++ b/packages/nexus/src/workflow-helpers.ts @@ -139,15 +139,10 @@ export type WorkflowRunOperationStartHandler = ( /** * A Nexus Operation implementation that is backed by a Workflow run. * - * The type parameter `O` represents the operation's output type. When the handler uses - * {@link startWorkflow} with a typed workflow function, `O` is inferred as the workflow's - * return type (e.g. `string`), since {@link startWorkflow} returns - * `WorkflowHandle>`. - * * @experimental Nexus support in Temporal SDK is experimental. */ export class WorkflowRunOperationHandler implements nexus.OperationHandler { - constructor(readonly handler: WorkflowRunOperationStartHandler) {} + constructor(readonly handler: WorkflowRunOperationStartHandler) { } async start(ctx: nexus.StartOperationContext, input: I): Promise> { const { namespace } = getHandlerContext(); diff --git a/packages/test/src/test-nexus-handler.ts b/packages/test/src/test-nexus-handler.ts index 1e97f73bd..a6ace145b 100644 --- a/packages/test/src/test-nexus-handler.ts +++ b/packages/test/src/test-nexus-handler.ts @@ -797,7 +797,6 @@ export async function echoWorkflow(input: string): Promise { test('WorkflowRunOperationHandler infers correct output type from typed workflow function', async (t) => { // When constructing WorkflowRunOperationHandler without explicit type parameters using a typed // workflow function, the operation output type should be inferred as the workflow's return type - // (e.g. string), not the workflow function type (e.g. (input: string) => Promise). const _stringOp: nexus.OperationHandler = new temporalnexus.WorkflowRunOperationHandler( async (ctx, input: string) => { return await temporalnexus.startWorkflow(ctx, echoWorkflow, { From 43b2ccc9bb1c8902bf3f2c97f69458fe292ed4d6 Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Tue, 17 Mar 2026 10:57:07 -0700 Subject: [PATCH 3/3] run formatter --- packages/nexus/src/workflow-helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nexus/src/workflow-helpers.ts b/packages/nexus/src/workflow-helpers.ts index 9e4e568be..acdc7a3b2 100644 --- a/packages/nexus/src/workflow-helpers.ts +++ b/packages/nexus/src/workflow-helpers.ts @@ -142,7 +142,7 @@ export type WorkflowRunOperationStartHandler = ( * @experimental Nexus support in Temporal SDK is experimental. */ export class WorkflowRunOperationHandler implements nexus.OperationHandler { - constructor(readonly handler: WorkflowRunOperationStartHandler) { } + constructor(readonly handler: WorkflowRunOperationStartHandler) {} async start(ctx: nexus.StartOperationContext, input: I): Promise> { const { namespace } = getHandlerContext();