Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions packages/nexus/src/workflow-helpers.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<string>` and `WorkflowHandle<number>` are structurally distinct.
*
* @experimental Nexus support in Temporal SDK is experimental.
*/
export interface WorkflowHandle<_T> {
export interface WorkflowHandle<T> {
readonly workflowId: string;
readonly runId: string;

Expand All @@ -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<X>` structurally
* distinct from `WorkflowHandle<Y>` so TypeScript can catch type mismatches.
*
* @internal
* @hidden
*
* @experimental Nexus support in Temporal SDK is experimental.
*/
readonly [workflowResultType]: T;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like a better structure IMHO.

Suggested change
readonly [workflowResultType]: T;
readonly [workflowResultType]: WorkflowResult<T>;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I explored this option, but it actually conflicts with explicit typing. Including that restricts T to T extends Workflow which has knock on effects that cause any explicitly provided type parameters to also extend Workflow. For example:

// Explicit type parameters should also work correctly.
  const _explicitStringOp: nexus.OperationHandler<string, string> = new temporalnexus.WorkflowRunOperationHandler<
    string,
    string
  >(async (ctx, input) => {
    return await temporalnexus.startWorkflow(ctx, echoWorkflow, {
      args: [input],
      workflowId: 'test',
    });
  });

Instead would need to be

// Explicit type parameters should also work correctly.
  const _explicitStringOp: nexus.OperationHandler<string, string> = new temporalnexus.WorkflowRunOperationHandler<
    string,
    typeof echoWorkflow
  >(async (ctx, input) => {
    return await temporalnexus.startWorkflow(ctx, echoWorkflow, {
      args: [input],
      workflowId: 'test',
    });
  });

}

/**
Expand All @@ -54,7 +70,7 @@ export async function startWorkflow<T extends Workflow>(
ctx: nexus.StartOperationContext,
workflowTypeOrFunc: string | T,
workflowOptions: WorkflowStartOptions<T>
): Promise<WorkflowHandle<T>> {
): Promise<WorkflowHandle<WorkflowResultType<T>>> {
const { client, taskQueue } = getHandlerContext();
const links = Array<temporal.api.common.v1.ILink>();
if (ctx.inboundLinks?.length > 0) {
Expand Down Expand Up @@ -107,7 +123,7 @@ export async function startWorkflow<T extends Workflow>(
return {
workflowId: handle.workflowId,
runId: handle.firstExecutionRunId,
} as WorkflowHandle<T>;
} as WorkflowHandle<WorkflowResultType<T>>;
}

/**
Expand Down
83 changes: 83 additions & 0 deletions packages/test/src/test-nexus-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,89 @@ test('WorkflowRunOperationHandler does not accept WorkflowHandle from WorkflowCl
t.pass();
});

export async function echoWorkflow(input: string): Promise<string> {
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
const _stringOp: nexus.OperationHandler<string, string> = 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<string, number> = 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<string, string> = 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<string, number> = new temporalnexus.WorkflowRunOperationHandler<
string,
string
>(async (ctx, input) => {
return await temporalnexus.startWorkflow(ctx, echoWorkflow, {
args: [input],
workflowId: 'test',
});
});

const _explicitContradictsWorkflow: nexus.OperationHandler<string, number> =
// @ts-expect-error - Explicit type params <string, number> contradict echoWorkflow which returns string
new temporalnexus.WorkflowRunOperationHandler<string, number>(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<Workflow> resolves to `any`. This means the handler is assignable to any
// output type and TypeScript cannot catch mismatches.
const _stringNameOp: nexus.OperationHandler<string, string> = 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<string, number> = 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();
Expand Down
Loading