Skip to content

Commit fa07e89

Browse files
committed
feat(workflow): support TypeInfo for client-started workflows
1 parent eb968c5 commit fa07e89

16 files changed

Lines changed: 545 additions & 42 deletions

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ to docs, or any other relevant information.
2323

2424
- **Experimental**: Added `TypeInfo`, `TransferTypeConverter`, and `ConverterHint` to `@temporalio/common` for converting
2525
application values to and from serialization-friendly transfer types and supplying converter-specific metadata
26-
during payload conversion.
26+
during payload conversion, including client-started Workflow inputs and results.
2727

2828
## [1.21.1] - 2026-07-23
2929

packages/client/src/workflow-client.ts

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import type {
1111
WorkflowResultType,
1212
WorkflowIdConflictPolicy,
1313
WorkflowSerializationContext,
14+
WorkflowTypeOptions,
15+
PayloadTypeInfo,
1416
} from '@temporalio/common';
1517
import {
1618
CancelledFailure,
@@ -21,11 +23,11 @@ import {
2123
TimeoutType,
2224
WorkflowExecutionAlreadyStartedError,
2325
WorkflowNotFoundError,
24-
extractWorkflowType,
2526
encodeWorkflowIdReusePolicy,
2627
decodeRetryState,
2728
encodeWorkflowIdConflictPolicy,
2829
compilePriority,
30+
extractWorkflowTypeAndConfig,
2931
} from '@temporalio/common';
3032
import { encodeUserMetadata } from '@temporalio/common/lib/internal-non-workflow/codec-helpers';
3133
import { encodeUnifiedSearchAttributes } from '@temporalio/common/lib/converter/payload-search-attributes';
@@ -360,6 +362,16 @@ export interface WorkflowResultOptions {
360362
* @default true
361363
*/
362364
followRuns?: boolean;
365+
366+
/**
367+
* Type information used to decode the Workflow result.
368+
*
369+
* This is only needed when getting a result from an existing Workflow handle or
370+
* when the Workflow definition is not available to this client.
371+
*
372+
* @experimental
373+
*/
374+
typeInfo?: PayloadTypeInfo;
363375
}
364376

365377
/**
@@ -550,13 +562,16 @@ export class WorkflowClient extends BaseClient {
550562
}
551563

552564
protected async _start<T extends Workflow>(
553-
workflowTypeOrFunc: string | T,
565+
workflowTypeOptions: WorkflowTypeOptions,
554566
options: WorkflowStartOptions<T>,
555567
interceptors: WorkflowClientInterceptor[]
556568
): Promise<WorkflowStartOutput> {
557-
const workflowType = extractWorkflowType(workflowTypeOrFunc);
558569
assertRequiredWorkflowOptions(options);
559-
const compiledOptions = compileWorkflowOptions(ensureArgs(options));
570+
const workflowOptions = {
571+
...options,
572+
typeInfo: workflowTypeOptions.typeInfo,
573+
};
574+
const compiledOptions = compileWorkflowOptions(ensureArgs(workflowOptions));
560575
const adaptedInterceptors = interceptors.map((i) => adaptWorkflowClientInterceptor(i));
561576

562577
const startWithDetails = composeInterceptors(
@@ -568,7 +583,7 @@ export class WorkflowClient extends BaseClient {
568583
return startWithDetails({
569584
options: compiledOptions,
570585
headers: {},
571-
workflowType,
586+
workflowType: workflowTypeOptions.type,
572587
});
573588
}
574589

@@ -577,10 +592,14 @@ export class WorkflowClient extends BaseClient {
577592
options: WithWorkflowArgs<T, WorkflowSignalWithStartOptions<SA>>,
578593
interceptors: WorkflowClientInterceptor[]
579594
): Promise<string> {
580-
const workflowType = extractWorkflowType(workflowTypeOrFunc);
581595
const { signal, signalArgs, ...rest } = options;
596+
const { type: workflowType, typeInfo } = extractWorkflowTypeAndConfig(workflowTypeOrFunc, rest.typeInfo);
582597
assertRequiredWorkflowOptions(rest);
583-
const compiledOptions = compileWorkflowOptions(ensureArgs(rest));
598+
const workflowOptions = {
599+
...rest,
600+
typeInfo,
601+
};
602+
const compiledOptions = compileWorkflowOptions(ensureArgs(workflowOptions));
584603
const signalWithStart = composeInterceptors(
585604
interceptors,
586605
'signalWithStart',
@@ -607,7 +626,8 @@ export class WorkflowClient extends BaseClient {
607626
): Promise<WorkflowHandleWithStartDetails<T>> {
608627
const { workflowId } = options;
609628
const interceptors = this.getOrMakeInterceptors(workflowId);
610-
const wfStartOutput = await this._start(workflowTypeOrFunc, { ...options, workflowId }, interceptors);
629+
const workflowTypeOptions = extractWorkflowTypeAndConfig(workflowTypeOrFunc, options.typeInfo);
630+
const wfStartOutput = await this._start(workflowTypeOptions, { ...options, workflowId }, interceptors);
611631
// runId is not used in handles created with `start*` calls because these
612632
// handles should allow interacting with the workflow if it continues as new.
613633
const baseHandle = this._createWorkflowHandle({
@@ -617,6 +637,7 @@ export class WorkflowClient extends BaseClient {
617637
runIdForResult: wfStartOutput.runId,
618638
interceptors,
619639
followRuns: options.followRuns ?? true,
640+
typeInfo: workflowTypeOptions.typeInfo,
620641
});
621642
return {
622643
...baseHandle,
@@ -642,6 +663,7 @@ export class WorkflowClient extends BaseClient {
642663
): Promise<WorkflowHandleWithSignaledRunId<WorkflowFn>> {
643664
const { workflowId } = options;
644665
const interceptors = this.getOrMakeInterceptors(workflowId);
666+
const workflowTypeOptions = extractWorkflowTypeAndConfig(workflowTypeOrFunc, options.typeInfo);
645667
const runId = await this._signalWithStart(workflowTypeOrFunc, options, interceptors);
646668
// runId is not used in handles created with `start*` calls because these
647669
// handles should allow interacting with the workflow if it continues as new.
@@ -652,6 +674,7 @@ export class WorkflowClient extends BaseClient {
652674
runIdForResult: runId,
653675
interceptors,
654676
followRuns: options.followRuns ?? true,
677+
typeInfo: workflowTypeOptions.typeInfo,
655678
}) as WorkflowHandleWithSignaledRunId<WorkflowFn>; // Cast is safe because we know we add the signaledRunId below
656679
(handle as any) /* readonly */.signaledRunId = runId;
657680
return handle;
@@ -736,11 +759,16 @@ export class WorkflowClient extends BaseClient {
736759
throw new Error('This WithStartWorkflowOperation instance has already been executed.');
737760
}
738761
startWorkflowOperation[withStartWorkflowOperationUsed] = true;
762+
const { type: workflowType, typeInfo } = extractWorkflowTypeAndConfig(workflowTypeOrFunc, workflowOptions.typeInfo);
739763
assertRequiredWorkflowOptions(workflowOptions);
740764

765+
const resolvedWorkflowOptions = {
766+
...workflowOptions,
767+
typeInfo,
768+
};
741769
const startUpdateWithStartInput: WorkflowStartUpdateWithStartInput = {
742-
workflowType: extractWorkflowType(workflowTypeOrFunc),
743-
workflowStartOptions: compileWorkflowOptions(ensureArgs(workflowOptions)),
770+
workflowType,
771+
workflowStartOptions: compileWorkflowOptions(ensureArgs(resolvedWorkflowOptions)),
744772
workflowStartHeaders: {},
745773
updateName: typeof updateDef === 'string' ? updateDef : updateDef.name,
746774
updateArgs: args ?? [],
@@ -757,6 +785,7 @@ export class WorkflowClient extends BaseClient {
757785
firstExecutionRunId: startResponse.runId ?? undefined,
758786
interceptors,
759787
followRuns: workflowOptions.followRuns ?? true,
788+
typeInfo,
760789
})
761790
);
762791

@@ -798,10 +827,12 @@ export class WorkflowClient extends BaseClient {
798827
): Promise<WorkflowResultType<T>> {
799828
const { workflowId } = options;
800829
const interceptors = this.getOrMakeInterceptors(workflowId);
801-
await this._start(workflowTypeOrFunc, options, interceptors);
830+
const workflowTypeOptions = extractWorkflowTypeAndConfig(workflowTypeOrFunc, options.typeInfo);
831+
await this._start(workflowTypeOptions, options, interceptors);
802832
return await this.result(workflowId, undefined, {
803833
...options,
804834
followRuns: options.followRuns ?? true,
835+
typeInfo: workflowTypeOptions.typeInfo,
805836
});
806837
}
807838

@@ -857,12 +888,14 @@ export class WorkflowClient extends BaseClient {
857888
}
858889
// Note that we can only return one value from our workflow function in JS.
859890
// Ignore any other payloads in result
860-
const [result] = await decodeArrayFromPayloads(
891+
const result = await decodeFromPayloadsAtIndex<WorkflowResultType<T>>(
861892
dataConverter,
893+
0,
862894
ev.workflowExecutionCompletedEventAttributes.result?.payloads,
863-
context
895+
context,
896+
opts?.typeInfo?.outputType
864897
);
865-
return result as any;
898+
return result;
866899
} else if (ev.workflowExecutionFailedEventAttributes) {
867900
if (followRuns && ev.workflowExecutionFailedEventAttributes.newExecutionRunId) {
868901
execution.runId = ev.workflowExecutionFailedEventAttributes.newExecutionRunId;
@@ -1335,7 +1368,9 @@ export class WorkflowClient extends BaseClient {
13351368
workflowIdReusePolicy: encodeWorkflowIdReusePolicy(options.workflowIdReusePolicy),
13361369
workflowIdConflictPolicy: encodeWorkflowIdConflictPolicy(options.workflowIdConflictPolicy),
13371370
workflowType: { name: workflowType },
1338-
input: { payloads: await encodeToPayloadsWithContext(dataConverter, context, options.args) },
1371+
input: {
1372+
payloads: await encodeToPayloadsWithContext(dataConverter, context, options.args, options.typeInfo?.inputTypes),
1373+
},
13391374
signalName,
13401375
signalInput: { payloads: await encodeToPayloadsWithContext(dataConverter, context, signalArgs) },
13411376
taskQueue: {
@@ -1465,7 +1500,9 @@ export class WorkflowClient extends BaseClient {
14651500
workflowIdReusePolicy: encodeWorkflowIdReusePolicy(opts.workflowIdReusePolicy),
14661501
workflowIdConflictPolicy: encodeWorkflowIdConflictPolicy(opts.workflowIdConflictPolicy),
14671502
workflowType: { name: workflowType },
1468-
input: { payloads: await encodeToPayloadsWithContext(dataConverter, context, opts.args) },
1503+
input: {
1504+
payloads: await encodeToPayloadsWithContext(dataConverter, context, opts.args, opts.typeInfo?.inputTypes),
1505+
},
14691506
taskQueue: {
14701507
kind: temporal.api.enums.v1.TaskQueueKind.TASK_QUEUE_KIND_NORMAL,
14711508
name: opts.taskQueue,
@@ -1752,6 +1789,7 @@ export class WorkflowClient extends BaseClient {
17521789
runIdForResult: runId ?? options?.firstExecutionRunId,
17531790
interceptors,
17541791
followRuns: options?.followRuns ?? true,
1792+
typeInfo: options?.typeInfo,
17551793
});
17561794
}
17571795

packages/client/src/workflow-options.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
WithWorkflowArgs,
55
Workflow,
66
VersioningOverride,
7+
PayloadTypeInfo,
78
} from '@temporalio/common';
89
import { toCanonicalString } from '@temporalio/common';
910
import type { Duration } from '@temporalio/common/lib/time';
@@ -60,6 +61,13 @@ export interface WorkflowOptions extends CommonWorkflowOptions {
6061
* start it on a local worker running with this same client.
6162
*/
6263
requestEagerStart?: boolean;
64+
65+
/**
66+
* Type information used to encode and decode Workflow input and output.
67+
*
68+
* @experimental
69+
*/
70+
typeInfo?: PayloadTypeInfo;
6371
}
6472

6573
export type WithCompiledWorkflowOptions<T extends WorkflowOptions> = Replace<
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import test from 'ava';
2+
import type { PayloadTypeInfo } from '../type-info';
3+
import { extractWorkflowType, extractWorkflowTypeAndConfig } from '../workflow-options';
4+
5+
const typeInfo: PayloadTypeInfo = {
6+
inputTypes: [{}],
7+
outputType: {},
8+
};
9+
10+
test('resolves call-site type information for a string Workflow type', (t) => {
11+
t.deepEqual(extractWorkflowTypeAndConfig('workflow', typeInfo), {
12+
type: 'workflow',
13+
typeInfo,
14+
});
15+
});
16+
17+
test('resolves definition-supplied type information for a Workflow function', (t) => {
18+
const workflow = Object.assign(async function workflow(): Promise<void> {}, {
19+
staticOptions: { typeInfo },
20+
});
21+
22+
t.deepEqual(extractWorkflowTypeAndConfig(workflow), {
23+
type: 'workflow',
24+
typeInfo,
25+
});
26+
t.is(extractWorkflowType(workflow), 'workflow');
27+
});
28+
29+
test('rejects call-site type information for a Workflow function', (t) => {
30+
async function workflow(): Promise<void> {}
31+
32+
t.throws(() => extractWorkflowTypeAndConfig(workflow, typeInfo), {
33+
instanceOf: TypeError,
34+
message: /Workflow type information cannot be supplied at the call site/,
35+
});
36+
});
37+
38+
test('ignores explicitly undefined static options', (t) => {
39+
const workflow = Object.assign(async function workflow(): Promise<void> {}, {
40+
staticOptions: undefined,
41+
});
42+
43+
t.deepEqual(extractWorkflowTypeAndConfig(workflow), {
44+
type: 'workflow',
45+
typeInfo: undefined,
46+
});
47+
});

packages/common/src/converter/payload-converter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export function fromPayloadsAtIndex<T>(
118118
index: number,
119119
payloads?: Payload[] | null,
120120
context?: SerializationContext,
121-
valueTypeInfo?: TypeInfo<T>
121+
valueTypeInfo?: TypeInfo
122122
): T {
123123
// To make adding arguments a backwards compatible change
124124
if (payloads === undefined || payloads === null || index >= payloads.length) {

packages/common/src/converter/type-info-aware-payload-converter.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,13 @@ export class TypeInfoAwarePayloadConverter implements PayloadConverter {
3535
public fromPayloadWithTypeInfo<T>(
3636
payload: Payload,
3737
context: SerializationContext | undefined,
38-
typeInfo: TypeInfo<T> | undefined
38+
typeInfo: TypeInfo | undefined
3939
): T {
4040
const transferValue = this.payloadConverter.fromPayload<unknown>(payload, context, typeInfo?.hint);
41-
return typeInfo?.transferTypeConverter
41+
const value = typeInfo?.transferTypeConverter
4242
? typeInfo.transferTypeConverter.fromTransferType(transferValue)
43-
: (transferValue as T);
43+
: transferValue;
44+
return value as T;
4445
}
4546
}
4647

packages/common/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export * from './priority';
3131
export * from './metrics';
3232
export * from './retry-policy';
3333
export type { Timestamp, Duration, StringValue } from './time';
34-
export { ConverterHint, TransferTypeConverter, TypeInfo, valueTypeBrand } from './type-info';
34+
export { ConverterHint, PayloadTypeInfo, TransferTypeConverter, TypeInfo, valueTypeBrand } from './type-info';
3535
export * from './worker-deployments';
3636
export * from './workflow-definition-options';
3737
export * from './workflow-handle';

packages/common/src/internal-non-workflow/codec-helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ export async function decodeFromPayloadsAtIndex<T>(
148148
index: number,
149149
payloads?: Payload[] | null,
150150
context?: SerializationContext,
151-
valueTypeInfo?: TypeInfo<T>
151+
valueTypeInfo?: TypeInfo
152152
): Promise<T> {
153153
const { payloadConverter, payloadCodecs } = converter;
154154
return await fromPayloadsAtIndex(

packages/common/src/type-info.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,9 @@ export interface ConverterHint<T = unknown> {
2525
converter: string;
2626
[valueTypeBrand]?: T;
2727
}
28+
29+
/** @experimental */
30+
export interface PayloadTypeInfo {
31+
inputTypes?: readonly TypeInfo[];
32+
outputType?: TypeInfo;
33+
}

0 commit comments

Comments
 (0)