From c7e9d4498543967c6bb57510e96c73d0fb272fb7 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Thu, 2 Jul 2026 13:58:02 -0400 Subject: [PATCH 01/14] init impl of type hints - workflow start flow --- packages/client/src/workflow-client.ts | 29 ++++++++--- .../common/src/converter/payload-converter.ts | 25 ++++++++-- packages/common/src/index.ts | 16 +++--- .../internal-non-workflow/codec-helpers.ts | 7 ++- packages/common/src/type-hints.ts | 6 +++ .../common/src/workflow-definition-options.ts | 44 +++++++++++++++- packages/common/src/workflow-options.ts | 50 ++++++++++++++++++- packages/workflow/src/internals.ts | 8 ++- packages/workflow/src/worker-interface.ts | 12 ++--- packages/workflow/src/workflow.ts | 18 +++++-- 10 files changed, 177 insertions(+), 38 deletions(-) create mode 100644 packages/common/src/type-hints.ts diff --git a/packages/client/src/workflow-client.ts b/packages/client/src/workflow-client.ts index 6e93602d4e..a5cea9a565 100644 --- a/packages/client/src/workflow-client.ts +++ b/packages/client/src/workflow-client.ts @@ -21,11 +21,11 @@ import { TimeoutType, WorkflowExecutionAlreadyStartedError, WorkflowNotFoundError, - extractWorkflowType, encodeWorkflowIdReusePolicy, decodeRetryState, encodeWorkflowIdConflictPolicy, compilePriority, + extractWorkflowTypeAndConfig, } from '@temporalio/common'; import { encodeUserMetadata } from '@temporalio/common/lib/internal-non-workflow/codec-helpers'; import { encodeUnifiedSearchAttributes } from '@temporalio/common/lib/converter/payload-search-attributes'; @@ -537,8 +537,10 @@ export class WorkflowClient extends BaseClient { options: WorkflowStartOptions, interceptors: WorkflowClientInterceptor[] ): Promise { - const workflowType = extractWorkflowType(workflowTypeOrFunc); + const workflowDefinitionConfig = extractWorkflowTypeAndConfig(workflowTypeOrFunc); assertRequiredWorkflowOptions(options); + // Resolve type hints (type hints provided at call site take precedence) + options.typeHints = options.typeHints ?? workflowDefinitionConfig.staticOptions?.typeHints; const compiledOptions = compileWorkflowOptions(ensureArgs(options)); const adaptedInterceptors = interceptors.map((i) => adaptWorkflowClientInterceptor(i)); @@ -551,7 +553,7 @@ export class WorkflowClient extends BaseClient { return startWithDetails({ options: compiledOptions, headers: {}, - workflowType, + workflowType: workflowDefinitionConfig.type, }); } @@ -560,10 +562,16 @@ export class WorkflowClient extends BaseClient { options: WithWorkflowArgs>, interceptors: WorkflowClientInterceptor[] ): Promise { - const workflowType = extractWorkflowType(workflowTypeOrFunc); + const workflowDefinitionConfig = extractWorkflowTypeAndConfig(workflowTypeOrFunc); const { signal, signalArgs, ...rest } = options; assertRequiredWorkflowOptions(rest); - const compiledOptions = compileWorkflowOptions(ensureArgs(rest)); + // Resolve type hints (type hints provided at call site take precedence) + const typeHints = rest.typeHints ?? workflowDefinitionConfig.staticOptions?.typeHints; + const workflowOptions = { + ...rest, + typeHints, + }; + const compiledOptions = compileWorkflowOptions(ensureArgs(workflowOptions)); const signalWithStart = composeInterceptors( interceptors, 'signalWithStart', @@ -573,7 +581,7 @@ export class WorkflowClient extends BaseClient { return signalWithStart({ options: compiledOptions, headers: {}, - workflowType, + workflowType: workflowDefinitionConfig.type, signalName: typeof signal === 'string' ? signal : signal.name, signalArgs: signalArgs ?? [], }); @@ -719,10 +727,13 @@ export class WorkflowClient extends BaseClient { throw new Error('This WithStartWorkflowOperation instance has already been executed.'); } startWorkflowOperation[withStartWorkflowOperationUsed] = true; + const workflowDefinitionConfig = extractWorkflowTypeAndConfig(workflowTypeOrFunc); assertRequiredWorkflowOptions(workflowOptions); + // Resolve type hints (type hints provided at call site take precedence) + workflowOptions.typeHints = workflowOptions.typeHints ?? workflowDefinitionConfig.staticOptions?.typeHints; const startUpdateWithStartInput: WorkflowStartUpdateWithStartInput = { - workflowType: extractWorkflowType(workflowTypeOrFunc), + workflowType: workflowDefinitionConfig.type, workflowStartOptions: compileWorkflowOptions(ensureArgs(workflowOptions)), workflowStartHeaders: {}, updateName: typeof updateDef === 'string' ? updateDef : updateDef.name, @@ -1352,7 +1363,7 @@ export class WorkflowClient extends BaseClient { workflowIdReusePolicy: encodeWorkflowIdReusePolicy(opts.workflowIdReusePolicy), workflowIdConflictPolicy: encodeWorkflowIdConflictPolicy(opts.workflowIdConflictPolicy), workflowType: { name: workflowType }, - input: { payloads: await encodeToPayloadsWithContext(dataConverter, context, opts.args) }, + input: { payloads: await encodeToPayloadsWithContext(dataConverter, context, opts.args, opts.typeHints?.inputTypes) }, taskQueue: { kind: temporal.api.enums.v1.TaskQueueKind.TASK_QUEUE_KIND_NORMAL, name: opts.taskQueue, @@ -1362,6 +1373,8 @@ export class WorkflowClient extends BaseClient { workflowTaskTimeout: opts.workflowTaskTimeout, workflowStartDelay: opts.startDelay, retryPolicy: opts.retry ? compileRetryPolicy(opts.retry) : undefined, + // TYPE HINTS: skipping memo for now + // (can support by adjusting typeHints field in BaseWorkflowOptions and WorkflowDefinitionConfig) memo: opts.memo ? { fields: await encodeMapToPayloads(dataConverter, opts.memo, context) } : undefined, searchAttributes: opts.searchAttributes || opts.typedSearchAttributes diff --git a/packages/common/src/converter/payload-converter.ts b/packages/common/src/converter/payload-converter.ts index dc7e420972..56ed825233 100644 --- a/packages/common/src/converter/payload-converter.ts +++ b/packages/common/src/converter/payload-converter.ts @@ -1,6 +1,7 @@ import { decode, encode } from '../encoding'; import { PayloadConverterError, ValueError } from '../errors'; import type { Payload } from '../interfaces'; +import { PayloadTypeHints, TypeHint } from '../type-hints'; import type { SerializationContext } from './serialization-context'; import { encodingKeys, encodingTypes, METADATA_ENCODING_KEY } from './types'; @@ -20,12 +21,17 @@ export interface PayloadConverter { * * Should throw {@link ValueError} if unable to convert. */ - toPayload(value: T, context?: SerializationContext): Payload; + toPayload(value: T, context?: SerializationContext, typeHint?: TypeHint): Payload; /** * Converts a {@link Payload} back to a value. */ - fromPayload(payload: Payload, context?: SerializationContext): T; + fromPayload(payload: Payload, context?: SerializationContext, typeHint?: TypeHint): T; + + /** + * Optional method to validate a type hint for the converter + */ + validateTypeHint?: (typeHint: TypeHint) => boolean; } /** @@ -49,13 +55,17 @@ export function toPayloads(converter: PayloadConverter, ...values: unknown[]): P export function toPayloadsWithContext( converter: PayloadConverter, context: SerializationContext | undefined, - values: unknown[] + values: unknown[], + typeHints?: readonly TypeHint[], ): Payload[] | undefined { if (values.length === 0) { return undefined; } + if (typeHints && typeHints.length !== values.length) { + throw new ValueError(`Got ${typeHints.length} type hints for ${values.length} values`); + } - return values.map((value) => converter.toPayload(value, context)); + return values.map((value, index) => converter.toPayload(value, context, typeHints?.[index])); } /** @@ -120,11 +130,16 @@ export function fromPayloadsAtIndex( export function arrayFromPayloads( converter: PayloadConverter, payloads?: Payload[] | null, - context?: SerializationContext + context?: SerializationContext, + typeHints?: readonly TypeHint[] ): unknown[] { if (!payloads) { return []; } + if (typeHints && typeHints.length !== payloads.length) { + throw new ValueError(`Got ${typeHints.length} type hints for ${payloads.length} values`); + } + return payloads.map((payload: Payload) => converter.fromPayload(payload, context)); } diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index a501b23d02..83f8291619 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -29,12 +29,6 @@ export * from './logger'; export * from './priority'; export * from './metrics'; export * from './retry-policy'; -export type { Timestamp, Duration, StringValue } from './time'; -export * from './worker-deployments'; -export * from './workflow-definition-options'; -export * from './workflow-handle'; -export * from './workflow-options'; -export * from './versioning-intent'; export { SearchAttributes, SearchAttributeValue, @@ -44,6 +38,16 @@ export { TypedSearchAttributes, defineSearchAttributeKey, } from './search-attributes'; +export type { Timestamp, Duration, StringValue } from './time'; +export { + TypeHint, + PayloadTypeHints +} from './type-hints'; +export * from './worker-deployments'; +export * from './workflow-definition-options'; +export * from './workflow-handle'; +export * from './workflow-options'; +export * from './versioning-intent'; /** * Encode a UTF-8 string into a Uint8Array diff --git a/packages/common/src/internal-non-workflow/codec-helpers.ts b/packages/common/src/internal-non-workflow/codec-helpers.ts index 9af3302768..b32c3d5e53 100644 --- a/packages/common/src/internal-non-workflow/codec-helpers.ts +++ b/packages/common/src/internal-non-workflow/codec-helpers.ts @@ -13,6 +13,7 @@ import type { LoadedDataConverter } from '../converter/data-converter'; import type { UserMetadata } from '../user-metadata'; import type { SerializationContext } from '../converter/serialization-context'; import type { DecodedPayload, DecodedProtoFailure, EncodedPayload, EncodedProtoFailure } from './codec-types'; +import { TypeHint } from '../type-hints'; /** * Decode through each codec, starting with the last codec. @@ -195,13 +196,15 @@ export async function encodeToPayloads( export async function encodeToPayloadsWithContext( converter: LoadedDataConverter, context: SerializationContext | undefined, - values: unknown[] + values: unknown[], + typeHints?: readonly TypeHint[], ): Promise { const { payloadConverter, payloadCodecs } = converter; if (values.length === 0) { return undefined; } - const payloads = toPayloadsWithContext(payloadConverter, context, values); + + const payloads = toPayloadsWithContext(payloadConverter, context, values, typeHints); return payloads ? await encode(payloadCodecs, payloads, context) : undefined; } diff --git a/packages/common/src/type-hints.ts b/packages/common/src/type-hints.ts new file mode 100644 index 0000000000..2e5d5cfe8e --- /dev/null +++ b/packages/common/src/type-hints.ts @@ -0,0 +1,6 @@ +export type TypeHint = unknown; + +export interface PayloadTypeHints { + inputTypes?: readonly TypeHint[]; + outputType?: TypeHint; +} \ No newline at end of file diff --git a/packages/common/src/workflow-definition-options.ts b/packages/common/src/workflow-definition-options.ts index d8e2a3629a..18ac3523ec 100644 --- a/packages/common/src/workflow-definition-options.ts +++ b/packages/common/src/workflow-definition-options.ts @@ -1,5 +1,15 @@ +import { PayloadTypeHints } from './type-hints'; import type { VersioningBehavior } from './worker-deployments'; +/** NEEDS REAL DOCSTRINGS */ +export interface WorkflowDefinitionConfig { + // Options that can be set per workflow execution + // THOMAS - i'd like to rename this to instanceOptions + workflowDefinitionOptions?: WorkflowDefinitionOptionsOrGetter; + // Options that are set once at workflow definition (static) + staticOptions?: WorkflowStaticOptions; +} + /** * Options that can be used when defining a workflow via {@link setWorkflowOptions}. */ @@ -30,6 +40,17 @@ export interface WorkflowDefinitionOptions { failureExceptionTypes?: Array Error>; } +export interface WorkflowStaticOptions { + /** + * Type hints! For workflow input/output. + * + * Validated at compile-time (can we do this?) + * + * @experimental + */ + typeHints?: PayloadTypeHints; +} + type AsyncFunction = (...args: Args) => Promise; export type WorkflowDefinitionOptionsOrGetter = WorkflowDefinitionOptions | (() => WorkflowDefinitionOptions); @@ -38,6 +59,25 @@ export type WorkflowDefinitionOptionsOrGetter = WorkflowDefinitionOptions | (() * @hidden * A workflow function that has been defined with options from {@link WorkflowDefinitionOptions}. */ -export interface WorkflowFunctionWithOptions extends AsyncFunction { - workflowDefinitionOptions: WorkflowDefinitionOptionsOrGetter; +export type WorkflowFunctionWithOptions = + AsyncFunction & + Required>; + +/** @internal */ +export type WorkflowFunctionWithStaticOptions = + AsyncFunction & + Required>; + + +const workflowDefinitionOptionsProperty = 'workflowDefinitionOptions' satisfies keyof WorkflowFunctionWithOptions; +const workflowStaticOptionsProperty = 'staticOptions' satisfies keyof WorkflowFunctionWithStaticOptions; + +/** @internal */ +export function isWorkflowFunctionWithOptions(obj: unknown): obj is WorkflowFunctionWithOptions { + return typeof obj === 'function' && Object.hasOwn(obj, workflowDefinitionOptionsProperty); } + +/** @internal */ +export function isWorkflowFunctionWithStaticOptions(obj: unknown): obj is WorkflowFunctionWithStaticOptions { + return typeof obj === 'function' && Object.hasOwn(obj, workflowStaticOptionsProperty); +} \ No newline at end of file diff --git a/packages/common/src/workflow-options.ts b/packages/common/src/workflow-options.ts index 19e27d95ef..911378e4db 100644 --- a/packages/common/src/workflow-options.ts +++ b/packages/common/src/workflow-options.ts @@ -5,8 +5,8 @@ import type { Duration } from './time'; import { makeProtoEnumConverters } from './internal-workflow'; import type { SearchAttributePair, SearchAttributes, TypedSearchAttributes } from './search-attributes'; import type { Priority } from './priority'; -import type { WorkflowFunctionWithOptions } from './workflow-definition-options'; - +import type { WorkflowStaticOptions, WorkflowFunctionWithOptions, WorkflowDefinitionConfig } from './workflow-definition-options'; +import { PayloadTypeHints } from './type-hints'; /** * Defines what happens when trying to start a Workflow with the same ID as a *Closed* Workflow. * @@ -212,6 +212,15 @@ export interface BaseWorkflowOptions { * Priority of a workflow */ priority?: Priority; + + /** + * Type hints! For workflow input/output. + * + * Validated at runtime. + * + * @experimental + */ + typeHints?: PayloadTypeHints; } export type WithWorkflowArgs = T & @@ -259,6 +268,12 @@ export interface WorkflowDurationOptions { export type CommonWorkflowOptions = BaseWorkflowOptions & WorkflowDurationOptions; +export interface WorkflowTypeOptions { + type: string; + staticOptions?: WorkflowStaticOptions; +} + +// Maybe get rid of export function extractWorkflowType( workflowTypeOrFunc: string | T | WorkflowFunctionWithOptions ): string { @@ -271,3 +286,34 @@ export function extractWorkflowType( `Invalid workflow type: expected either a string or a function, got '${typeof workflowTypeOrFunc}'` ); } + +type WorkflowWithStaticOptions = Workflow & { + workflowStaticOptions: WorkflowStaticOptions; +}; + +function hasWorkflowStaticOptions(fn: Workflow): fn is WorkflowWithStaticOptions { + return Object.hasOwn(fn, 'workflowStaticOptions'); +} + +export function extractWorkflowTypeAndConfig( + workflowTypeOrFunc: string | T +): WorkflowTypeOptions { + if (typeof workflowTypeOrFunc === 'string') { + return { type: workflowTypeOrFunc } + } + if (typeof workflowTypeOrFunc === 'function') { + if (!workflowTypeOrFunc.name) { + throw new TypeError('Invalid workflow type: the workflow function is anonymous'); + } + const staticOptions = hasWorkflowStaticOptions(workflowTypeOrFunc) + ? workflowTypeOrFunc.workflowStaticOptions + : undefined; + return { + type: workflowTypeOrFunc.name, + staticOptions, + }; + } + throw new TypeError( + `Invalid workflow type: expected either a string or a function, got '${typeof workflowTypeOrFunc}'` + ); +} \ No newline at end of file diff --git a/packages/workflow/src/internals.ts b/packages/workflow/src/internals.ts index f2e60516ea..790a4c68aa 100644 --- a/packages/workflow/src/internals.ts +++ b/packages/workflow/src/internals.ts @@ -15,6 +15,7 @@ import type { VersioningBehavior, WorkflowDefinitionOptions, WorkflowSerializationContext, + PayloadTypeHints, } from '@temporalio/common'; import { defaultFailureConverter, @@ -502,6 +503,9 @@ export class Activator implements ActivationHandler { protected readonly stackTracesEnabled: boolean; + // THOMAS - maybe store static options directly? + public typeHints?: PayloadTypeHints; + constructor({ info, now, @@ -639,7 +643,7 @@ export class Activator implements ActivationHandler { executeWithLifecycleLogging(() => execute({ headers: activation.headers ?? {}, - args: arrayFromPayloads(this.payloadConverter, activation.arguments, context), + args: arrayFromPayloads(this.payloadConverter, activation.arguments, context, this.typeHints?.inputTypes), }) ).then(this.completeWorkflow.bind(this), this.handleWorkflowFailure.bind(this)) ); @@ -655,7 +659,7 @@ export class Activator implements ActivationHandler { searchAttributes: decodeSearchAttributes(searchAttributes?.indexedFields), typedSearchAttributes: decodeTypedSearchAttributes(searchAttributes?.indexedFields), - + // THOMAS - this is where we'd decode memo with type hints on workflow start memo: mapFromPayloads(this.payloadConverter, memo?.fields, context), lastResult: fromPayloadsAtIndex(this.payloadConverter, 0, lastCompletionResult?.payloads, context), lastFailure: diff --git a/packages/workflow/src/worker-interface.ts b/packages/workflow/src/worker-interface.ts index eb46119ec7..92a2735fa2 100644 --- a/packages/workflow/src/worker-interface.ts +++ b/packages/workflow/src/worker-interface.ts @@ -4,7 +4,7 @@ * @module */ import type { WorkflowFunctionWithOptions } from '@temporalio/common'; -import { encodeVersioningBehavior, IllegalStateError } from '@temporalio/common'; +import { encodeVersioningBehavior, IllegalStateError, isWorkflowFunctionWithOptions, isWorkflowFunctionWithStaticOptions } from '@temporalio/common'; import type { coresdk } from '@temporalio/proto'; import type { WorkflowInterceptorsFactory } from './interceptors'; import type { WorkflowCreateOptionsInternal } from './interfaces'; @@ -97,6 +97,9 @@ export function initRuntime(options: WorkflowCreateOptionsInternal): void { activator.workflowDefinitionOptionsGetter = activator.workflow.workflowDefinitionOptions; } } + if (isWorkflowFunctionWithStaticOptions(activator.workflow)) { + activator.typeHints = activator.workflow.staticOptions.typeHints; + } } catch (e) { try { // Core won't send an eviction job after an early error, so we are responsible for triggering @@ -314,9 +317,4 @@ export function destroy(): void { } } } -} - -function isWorkflowFunctionWithOptions(obj: any): obj is WorkflowFunctionWithOptions { - if (obj == null) return false; - return Object.hasOwn(obj, 'workflowDefinitionOptions'); -} +} \ No newline at end of file diff --git a/packages/workflow/src/workflow.ts b/packages/workflow/src/workflow.ts index fbf8f97791..71a265e7cc 100644 --- a/packages/workflow/src/workflow.ts +++ b/packages/workflow/src/workflow.ts @@ -17,6 +17,7 @@ import type { WorkflowUpdateValidatorType, SearchAttributeUpdatePair, WorkflowDefinitionOptionsOrGetter, + WorkflowDefinitionConfig, } from '@temporalio/common'; import { compileRetryPolicy, @@ -1763,6 +1764,9 @@ export function allHandlersFinished(): boolean { /** * Can be used to alter workflow functions with certain options specified at definition time. + * + * Prefer {@link defineWorkflowOptions} for new code, especially when setting static workflow + * metadata such as type hints. * * @example * For example: @@ -1797,11 +1801,17 @@ export function allHandlersFinished(): boolean { */ export function setWorkflowOptions( options: WorkflowDefinitionOptionsOrGetter, - fn: (...args: A) => Promise + fn: (...args: A) => Promise, ): void { - Object.assign(fn, { - workflowDefinitionOptions: options, - }); + return defineWorkflowOptions(fn, { workflowDefinitionOptions: options }) +} + +/** NEEDS DOCSTRING */ +export function defineWorkflowOptions( + fn: (...args: A) => Promise, + config: WorkflowDefinitionConfig, +): void { + Object.assign(fn, config); } export const stackTraceQuery = defineQuery('__stack_trace'); From 686c523cd1070acbbb961738e4e90e38b1248631 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Thu, 2 Jul 2026 14:18:57 -0400 Subject: [PATCH 02/14] formatting and fixes --- packages/client/src/workflow-client.ts | 20 +++++++++--- .../common/src/converter/payload-converter.ts | 17 ++++++---- packages/common/src/index.ts | 5 +-- .../internal-non-workflow/codec-helpers.ts | 4 +-- packages/common/src/type-hints.ts | 6 ++-- .../common/src/workflow-definition-options.ts | 22 +++++++------ packages/common/src/workflow-options.ts | 32 ++++++++----------- packages/workflow/src/worker-interface.ts | 9 ++++-- packages/workflow/src/workflow.ts | 8 ++--- 9 files changed, 68 insertions(+), 55 deletions(-) diff --git a/packages/client/src/workflow-client.ts b/packages/client/src/workflow-client.ts index a5cea9a565..8107e4fd16 100644 --- a/packages/client/src/workflow-client.ts +++ b/packages/client/src/workflow-client.ts @@ -540,8 +540,12 @@ export class WorkflowClient extends BaseClient { const workflowDefinitionConfig = extractWorkflowTypeAndConfig(workflowTypeOrFunc); assertRequiredWorkflowOptions(options); // Resolve type hints (type hints provided at call site take precedence) - options.typeHints = options.typeHints ?? workflowDefinitionConfig.staticOptions?.typeHints; - const compiledOptions = compileWorkflowOptions(ensureArgs(options)); + const typeHints = options.typeHints ?? workflowDefinitionConfig.staticOptions?.typeHints; + const workflowOptions = { + ...options, + typeHints, + }; + const compiledOptions = compileWorkflowOptions(ensureArgs(workflowOptions)); const adaptedInterceptors = interceptors.map((i) => adaptWorkflowClientInterceptor(i)); const startWithDetails = composeInterceptors( @@ -731,10 +735,14 @@ export class WorkflowClient extends BaseClient { assertRequiredWorkflowOptions(workflowOptions); // Resolve type hints (type hints provided at call site take precedence) - workflowOptions.typeHints = workflowOptions.typeHints ?? workflowDefinitionConfig.staticOptions?.typeHints; + const typeHints = workflowOptions.typeHints ?? workflowDefinitionConfig.staticOptions?.typeHints; + const resolvedWorkflowOptions = { + ...workflowOptions, + typeHints, + }; const startUpdateWithStartInput: WorkflowStartUpdateWithStartInput = { workflowType: workflowDefinitionConfig.type, - workflowStartOptions: compileWorkflowOptions(ensureArgs(workflowOptions)), + workflowStartOptions: compileWorkflowOptions(ensureArgs(resolvedWorkflowOptions)), workflowStartHeaders: {}, updateName: typeof updateDef === 'string' ? updateDef : updateDef.name, updateArgs: args ?? [], @@ -1363,7 +1371,9 @@ export class WorkflowClient extends BaseClient { workflowIdReusePolicy: encodeWorkflowIdReusePolicy(opts.workflowIdReusePolicy), workflowIdConflictPolicy: encodeWorkflowIdConflictPolicy(opts.workflowIdConflictPolicy), workflowType: { name: workflowType }, - input: { payloads: await encodeToPayloadsWithContext(dataConverter, context, opts.args, opts.typeHints?.inputTypes) }, + input: { + payloads: await encodeToPayloadsWithContext(dataConverter, context, opts.args, opts.typeHints?.inputTypes), + }, taskQueue: { kind: temporal.api.enums.v1.TaskQueueKind.TASK_QUEUE_KIND_NORMAL, name: opts.taskQueue, diff --git a/packages/common/src/converter/payload-converter.ts b/packages/common/src/converter/payload-converter.ts index 56ed825233..81ecc6218d 100644 --- a/packages/common/src/converter/payload-converter.ts +++ b/packages/common/src/converter/payload-converter.ts @@ -56,7 +56,7 @@ export function toPayloadsWithContext( converter: PayloadConverter, context: SerializationContext | undefined, values: unknown[], - typeHints?: readonly TypeHint[], + typeHints?: readonly TypeHint[] ): Payload[] | undefined { if (values.length === 0) { return undefined; @@ -140,7 +140,7 @@ export function arrayFromPayloads( throw new ValueError(`Got ${typeHints.length} type hints for ${payloads.length} values`); } - return payloads.map((payload: Payload) => converter.fromPayload(payload, context)); + return payloads.map((payload: Payload, index) => converter.fromPayload(payload, context, typeHints?.[index])); } export function mapFromPayloads( @@ -187,14 +187,19 @@ export interface PayloadConverterWithEncoding { * @param value The value to convert. Example values include the Workflow args sent from the Client and the values returned by a Workflow or Activity. * @returns The {@link Payload}, or `undefined` if unable to convert. */ - toPayload(value: T, context?: SerializationContext): Payload | undefined; + toPayload(value: T, context?: SerializationContext, typeHint?: TypeHint): Payload | undefined; /** * Converts a {@link Payload} back to a value. */ - fromPayload(payload: Payload, context?: SerializationContext): T; + fromPayload(payload: Payload, context?: SerializationContext, typeHint?: TypeHint): T; readonly encodingType: string; + + /** + * Optional method to validate a type hint for the converter + */ + validateTypeHint?: (typeHint: TypeHint) => boolean; } /** @@ -222,12 +227,12 @@ export class CompositePayloadConverter implements PayloadConverter { * Tries to run `.toPayload(value)` on each converter in the order provided at construction. * Returns the first successful result, throws {@link ValueError} if there is no converter that can handle the value. */ - public toPayload(value: T, context?: SerializationContext): Payload { + public toPayload(value: T, context?: SerializationContext, typeHint?: TypeHint): Payload { if (value instanceof RawValue) { return value.payload; } for (const converter of this.converters) { - const result = converter.toPayload(value, context); + const result = converter.toPayload(value, context, typeHint); if (result !== undefined) { return result; } diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 83f8291619..0f928afa0f 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -39,10 +39,7 @@ export { defineSearchAttributeKey, } from './search-attributes'; export type { Timestamp, Duration, StringValue } from './time'; -export { - TypeHint, - PayloadTypeHints -} from './type-hints'; +export { TypeHint, PayloadTypeHints } from './type-hints'; export * from './worker-deployments'; export * from './workflow-definition-options'; export * from './workflow-handle'; diff --git a/packages/common/src/internal-non-workflow/codec-helpers.ts b/packages/common/src/internal-non-workflow/codec-helpers.ts index b32c3d5e53..da9b0339e9 100644 --- a/packages/common/src/internal-non-workflow/codec-helpers.ts +++ b/packages/common/src/internal-non-workflow/codec-helpers.ts @@ -197,13 +197,13 @@ export async function encodeToPayloadsWithContext( converter: LoadedDataConverter, context: SerializationContext | undefined, values: unknown[], - typeHints?: readonly TypeHint[], + typeHints?: readonly TypeHint[] ): Promise { const { payloadConverter, payloadCodecs } = converter; if (values.length === 0) { return undefined; } - + const payloads = toPayloadsWithContext(payloadConverter, context, values, typeHints); return payloads ? await encode(payloadCodecs, payloads, context) : undefined; } diff --git a/packages/common/src/type-hints.ts b/packages/common/src/type-hints.ts index 2e5d5cfe8e..3fdf8dc0c2 100644 --- a/packages/common/src/type-hints.ts +++ b/packages/common/src/type-hints.ts @@ -1,6 +1,6 @@ export type TypeHint = unknown; export interface PayloadTypeHints { - inputTypes?: readonly TypeHint[]; - outputType?: TypeHint; -} \ No newline at end of file + inputTypes?: readonly TypeHint[]; + outputType?: TypeHint; +} diff --git a/packages/common/src/workflow-definition-options.ts b/packages/common/src/workflow-definition-options.ts index 18ac3523ec..36f53b8d08 100644 --- a/packages/common/src/workflow-definition-options.ts +++ b/packages/common/src/workflow-definition-options.ts @@ -43,9 +43,9 @@ export interface WorkflowDefinitionOptions { export interface WorkflowStaticOptions { /** * Type hints! For workflow input/output. - * + * * Validated at compile-time (can we do this?) - * + * * @experimental */ typeHints?: PayloadTypeHints; @@ -59,17 +59,17 @@ export type WorkflowDefinitionOptionsOrGetter = WorkflowDefinitionOptions | (() * @hidden * A workflow function that has been defined with options from {@link WorkflowDefinitionOptions}. */ -export type WorkflowFunctionWithOptions = - AsyncFunction & +export type WorkflowFunctionWithOptions = AsyncFunction & Required>; /** @internal */ -export type WorkflowFunctionWithStaticOptions = - AsyncFunction & +export type WorkflowFunctionWithStaticOptions = AsyncFunction & Required>; - -const workflowDefinitionOptionsProperty = 'workflowDefinitionOptions' satisfies keyof WorkflowFunctionWithOptions; +const workflowDefinitionOptionsProperty = 'workflowDefinitionOptions' satisfies keyof WorkflowFunctionWithOptions< + any[], + any +>; const workflowStaticOptionsProperty = 'staticOptions' satisfies keyof WorkflowFunctionWithStaticOptions; /** @internal */ @@ -78,6 +78,8 @@ export function isWorkflowFunctionWithOptions(obj: unknown): obj is WorkflowFunc } /** @internal */ -export function isWorkflowFunctionWithStaticOptions(obj: unknown): obj is WorkflowFunctionWithStaticOptions { +export function isWorkflowFunctionWithStaticOptions( + obj: unknown +): obj is WorkflowFunctionWithStaticOptions { return typeof obj === 'function' && Object.hasOwn(obj, workflowStaticOptionsProperty); -} \ No newline at end of file +} diff --git a/packages/common/src/workflow-options.ts b/packages/common/src/workflow-options.ts index 911378e4db..367dfcb093 100644 --- a/packages/common/src/workflow-options.ts +++ b/packages/common/src/workflow-options.ts @@ -5,7 +5,11 @@ import type { Duration } from './time'; import { makeProtoEnumConverters } from './internal-workflow'; import type { SearchAttributePair, SearchAttributes, TypedSearchAttributes } from './search-attributes'; import type { Priority } from './priority'; -import type { WorkflowStaticOptions, WorkflowFunctionWithOptions, WorkflowDefinitionConfig } from './workflow-definition-options'; +import { + type WorkflowStaticOptions, + type WorkflowFunctionWithOptions, + isWorkflowFunctionWithStaticOptions, +} from './workflow-definition-options'; import { PayloadTypeHints } from './type-hints'; /** * Defines what happens when trying to start a Workflow with the same ID as a *Closed* Workflow. @@ -215,9 +219,9 @@ export interface BaseWorkflowOptions { /** * Type hints! For workflow input/output. - * + * * Validated at runtime. - * + * * @experimental */ typeHints?: PayloadTypeHints; @@ -273,7 +277,7 @@ export interface WorkflowTypeOptions { staticOptions?: WorkflowStaticOptions; } -// Maybe get rid of +// THOMAS - Maybe get rid of this export function extractWorkflowType( workflowTypeOrFunc: string | T | WorkflowFunctionWithOptions ): string { @@ -287,26 +291,16 @@ export function extractWorkflowType( ); } -type WorkflowWithStaticOptions = Workflow & { - workflowStaticOptions: WorkflowStaticOptions; -}; - -function hasWorkflowStaticOptions(fn: Workflow): fn is WorkflowWithStaticOptions { - return Object.hasOwn(fn, 'workflowStaticOptions'); -} - -export function extractWorkflowTypeAndConfig( - workflowTypeOrFunc: string | T -): WorkflowTypeOptions { +export function extractWorkflowTypeAndConfig(workflowTypeOrFunc: string | T): WorkflowTypeOptions { if (typeof workflowTypeOrFunc === 'string') { - return { type: workflowTypeOrFunc } + return { type: workflowTypeOrFunc }; } if (typeof workflowTypeOrFunc === 'function') { if (!workflowTypeOrFunc.name) { throw new TypeError('Invalid workflow type: the workflow function is anonymous'); } - const staticOptions = hasWorkflowStaticOptions(workflowTypeOrFunc) - ? workflowTypeOrFunc.workflowStaticOptions + const staticOptions = isWorkflowFunctionWithStaticOptions(workflowTypeOrFunc) + ? workflowTypeOrFunc.staticOptions : undefined; return { type: workflowTypeOrFunc.name, @@ -316,4 +310,4 @@ export function extractWorkflowTypeAndConfig( throw new TypeError( `Invalid workflow type: expected either a string or a function, got '${typeof workflowTypeOrFunc}'` ); -} \ No newline at end of file +} diff --git a/packages/workflow/src/worker-interface.ts b/packages/workflow/src/worker-interface.ts index 92a2735fa2..8b7e30d03c 100644 --- a/packages/workflow/src/worker-interface.ts +++ b/packages/workflow/src/worker-interface.ts @@ -4,7 +4,12 @@ * @module */ import type { WorkflowFunctionWithOptions } from '@temporalio/common'; -import { encodeVersioningBehavior, IllegalStateError, isWorkflowFunctionWithOptions, isWorkflowFunctionWithStaticOptions } from '@temporalio/common'; +import { + encodeVersioningBehavior, + IllegalStateError, + isWorkflowFunctionWithOptions, + isWorkflowFunctionWithStaticOptions, +} from '@temporalio/common'; import type { coresdk } from '@temporalio/proto'; import type { WorkflowInterceptorsFactory } from './interceptors'; import type { WorkflowCreateOptionsInternal } from './interfaces'; @@ -317,4 +322,4 @@ export function destroy(): void { } } } -} \ No newline at end of file +} diff --git a/packages/workflow/src/workflow.ts b/packages/workflow/src/workflow.ts index 71a265e7cc..c69429fbd7 100644 --- a/packages/workflow/src/workflow.ts +++ b/packages/workflow/src/workflow.ts @@ -1764,7 +1764,7 @@ export function allHandlersFinished(): boolean { /** * Can be used to alter workflow functions with certain options specified at definition time. - * + * * Prefer {@link defineWorkflowOptions} for new code, especially when setting static workflow * metadata such as type hints. * @@ -1801,15 +1801,15 @@ export function allHandlersFinished(): boolean { */ export function setWorkflowOptions( options: WorkflowDefinitionOptionsOrGetter, - fn: (...args: A) => Promise, + fn: (...args: A) => Promise ): void { - return defineWorkflowOptions(fn, { workflowDefinitionOptions: options }) + return defineWorkflowOptions(fn, { workflowDefinitionOptions: options }); } /** NEEDS DOCSTRING */ export function defineWorkflowOptions( fn: (...args: A) => Promise, - config: WorkflowDefinitionConfig, + config: WorkflowDefinitionConfig ): void { Object.assign(fn, config); } From 5abc557b0e92b6600e705ba8b2ce673648c4d807 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Thu, 2 Jul 2026 15:24:36 -0400 Subject: [PATCH 03/14] add missing type hint on composite converter on from payload path --- packages/common/src/converter/payload-converter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/common/src/converter/payload-converter.ts b/packages/common/src/converter/payload-converter.ts index 81ecc6218d..0bce34953e 100644 --- a/packages/common/src/converter/payload-converter.ts +++ b/packages/common/src/converter/payload-converter.ts @@ -244,7 +244,7 @@ export class CompositePayloadConverter implements PayloadConverter { /** * Run {@link PayloadConverterWithEncoding.fromPayload} based on the `encoding` metadata of the {@link Payload}. */ - public fromPayload(payload: Payload, context?: SerializationContext): T { + public fromPayload(payload: Payload, context?: SerializationContext, typeHint?: TypeHint): T { if (payload.metadata === undefined || payload.metadata === null) { throw new ValueError('Missing payload metadata'); } @@ -254,7 +254,7 @@ export class CompositePayloadConverter implements PayloadConverter { if (converter === undefined) { throw new ValueError(`Unknown encoding: ${encoding}`); } - return converter.fromPayload(payload, context); + return converter.fromPayload(payload, context, typeHint); } } From 43e4360a08b0b390125c00218da3a97d5c367c58 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Thu, 2 Jul 2026 15:24:53 -0400 Subject: [PATCH 04/14] add integration test on workflow start path (input only) --- .../test/src/payload-converters/type-hints.ts | 59 +++++++++++ packages/test/src/test-type-hints.ts | 98 +++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 packages/test/src/payload-converters/type-hints.ts create mode 100644 packages/test/src/test-type-hints.ts diff --git a/packages/test/src/payload-converters/type-hints.ts b/packages/test/src/payload-converters/type-hints.ts new file mode 100644 index 0000000000..5eefbcae35 --- /dev/null +++ b/packages/test/src/payload-converters/type-hints.ts @@ -0,0 +1,59 @@ +import type { Payload, SerializationContext, TypeHint } from '@temporalio/common'; +import { + BinaryPayloadConverter, + CompositePayloadConverter, + JsonPayloadConverter, + UndefinedPayloadConverter, +} from '@temporalio/common'; + +interface MapperTypeHint { + toIntermediate(value: T): unknown; + fromIntermediate(value: unknown): T; +} + +function isMapperTypeHint(hint: unknown): hint is MapperTypeHint { + return ( + typeof hint === 'object' && + hint !== null && + typeof (hint as any).toIntermediate === 'function' && + typeof (hint as any).fromIntermediate === 'function' + ); +} + +class MapperJsonPayloadConverter extends JsonPayloadConverter { + validateTypeHint(hint: TypeHint): boolean { + return isMapperTypeHint(hint); + } + + toPayload(value: T, context?: SerializationContext, hint?: TypeHint): Payload | undefined { + if (hint === undefined) return super.toPayload(value); + if (!this.validateTypeHint(hint)) return undefined; + + const mapper = hint as MapperTypeHint; + + // Hint handles rich value -> JSON-safe intermediate. + const intermediate = mapper.toIntermediate(value); + + // Existing JSON converter handles JSON-safe intermediate -> Payload. + return super.toPayload(intermediate); + } + + fromPayload(payload: Payload, context?: SerializationContext, hint?: TypeHint): T { + if (hint === undefined) return super.fromPayload(payload); + if (!this.validateTypeHint(hint)) throw new Error('Invalid mapper type hint'); + + const mapper = hint as MapperTypeHint; + + // Existing JSON converter handles Payload -> JSON-safe intermediate. + const intermediate = super.fromPayload(payload); + + // Hint handles JSON-safe intermediate -> rich value. + return mapper.fromIntermediate(intermediate); + } +} + +export const payloadConverter = new CompositePayloadConverter( + new UndefinedPayloadConverter(), + new BinaryPayloadConverter(), + new MapperJsonPayloadConverter() +); diff --git a/packages/test/src/test-type-hints.ts b/packages/test/src/test-type-hints.ts new file mode 100644 index 0000000000..5c4bfe8022 --- /dev/null +++ b/packages/test/src/test-type-hints.ts @@ -0,0 +1,98 @@ +import { randomUUID } from 'crypto'; +import { Client } from '@temporalio/client'; +import type { PayloadTypeHints } from '@temporalio/common'; +import { workflowInterceptorModules } from '@temporalio/testing'; +import { bundleWorkflowCode } from '@temporalio/worker'; +import { defineWorkflowOptions } from '@temporalio/workflow'; +import type { TestWorkflowEnvironment } from './helpers'; +import { bundlerOptions } from './helpers'; +import type { Context } from './helpers-integration'; +import { + configurableHelpers, + createTestWorkflowEnvironment, + makeConfigurableEnvironmentTestFn, +} from './helpers-integration'; + +const converterPath = require.resolve('./payload-converters/type-hints'); +const dataConverter = { payloadConverterPath: converterPath }; + +class Order { + constructor( + readonly id: string, + readonly totalCents: bigint + ) {} + + summary(): string { + return `${this.id}:${this.totalCents}`; + } +} + +const orderHint = { + toIntermediate(value: Order): unknown { + return { id: value.id, totalCents: value.totalCents.toString() }; + }, + fromIntermediate(value: any): Order { + return new Order(value.id, BigInt(value.totalCents)); + }, +}; + +const typeHints: PayloadTypeHints = { inputTypes: [orderHint] }; + +defineWorkflowOptions(workflowStartWithTypeHint, { + staticOptions: { typeHints }, +}); +export async function workflowStartWithTypeHint(order: Order): Promise<{ + isOrder: boolean; + summary: string; + totalCentsType: string; +}> { + return { + isOrder: order instanceof Order, + summary: order instanceof Order ? order.summary() : 'not-an-order', + totalCentsType: typeof (order as any).totalCents, + }; +} + +const test = makeConfigurableEnvironmentTestFn({ + createTestContext: async () => { + const env = await createTestWorkflowEnvironment(); + const workflowBundle = await bundleWorkflowCode({ + ...bundlerOptions, + workflowInterceptorModules: [...workflowInterceptorModules], + workflowsPath: __filename, + payloadConverterPath: converterPath, + }); + return { env, workflowBundle }; + }, + teardown: async (c) => { + await c.env.teardown(); + }, +}); + +function makeClient(env: TestWorkflowEnvironment): Client { + return new Client({ + connection: env.client.connection, + namespace: env.client.options.namespace, + dataConverter, + }); +} + +test('workflow start uses definition-supplied input type hints', async (t) => { + const h = configurableHelpers(t, t.context.workflowBundle, t.context.env); + const client = makeClient(t.context.env); + const worker = await h.createWorker({ dataConverter }); + + await worker.runUntil(async () => { + const result = await client.workflow.execute(workflowStartWithTypeHint, { + workflowId: `wf-${randomUUID()}`, + taskQueue: h.taskQueue, + args: [new Order('order-1', 12345n)], + }); + + t.deepEqual(result, { + isOrder: true, + summary: 'order-1:12345', + totalCentsType: 'bigint', + }); + }); +}); From 417a5c269d6eafcd8e820b2deb02d535301ce313 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Thu, 2 Jul 2026 16:14:02 -0400 Subject: [PATCH 05/14] wire up type hint on encode for: schedules, child workflow, continue-as-new (also fix signalWithStart - actually supply the type hint when encoding) --- packages/client/src/schedule-helpers.ts | 19 +++++++++----- packages/client/src/schedule-types.ts | 8 +++++- packages/client/src/workflow-client.ts | 3 ++- packages/common/src/workflow-options.ts | 15 +---------- packages/workflow/src/workflow.ts | 35 +++++++++++++++++-------- 5 files changed, 47 insertions(+), 33 deletions(-) diff --git a/packages/client/src/schedule-helpers.ts b/packages/client/src/schedule-helpers.ts index 82f1bde1b6..656424d74c 100644 --- a/packages/client/src/schedule-helpers.ts +++ b/packages/client/src/schedule-helpers.ts @@ -5,7 +5,7 @@ import { compileRetryPolicy, decodePriority, decompileRetryPolicy, - extractWorkflowType, + extractWorkflowTypeAndConfig, } from '@temporalio/common'; import { encodeUserMetadata, decodeUserMetadata } from '@temporalio/common/lib/internal-non-workflow/codec-helpers'; import { @@ -198,14 +198,17 @@ export function decodeOptionalStructuredCalendarSpecs( } export function compileScheduleOptions(options: ScheduleOptions): CompiledScheduleOptions { - const workflowType = extractWorkflowType(options.action.workflowType); + const workflowDefinitionConfig = extractWorkflowTypeAndConfig(options.action.workflowType); + // Resolve type hints (type hints provided at call site take precedence) + const typeHints = options?.action.typeHints ?? workflowDefinitionConfig.staticOptions?.typeHints; return { ...options, action: { ...options.action, workflowId: options.action.workflowId ?? `${options.scheduleId}-workflow`, - workflowType, + workflowType: workflowDefinitionConfig.type, args: (options.action.args ?? []) as unknown[], + typeHints, }, }; } @@ -215,14 +218,17 @@ export function compileUpdatedScheduleOptions( options: ScheduleUpdateOptions ): CompiledScheduleUpdateOptions { const workflowTypeOrFunc = options.action.workflowType; - const workflowType = extractWorkflowType(workflowTypeOrFunc); + const workflowDefinitionConfig = extractWorkflowTypeAndConfig(workflowTypeOrFunc); + // Resolve type hints (type hints provided at call site take precedence) + const typeHints = options?.action.typeHints ?? workflowDefinitionConfig.staticOptions?.typeHints; return { ...options, action: { ...options.action, workflowId: options.action.workflowId ?? `${scheduleId}-workflow`, - workflowType, + workflowType: workflowDefinitionConfig.type, args: (options.action.args ?? []) as unknown[], + typeHints, }, }; } @@ -264,7 +270,7 @@ export async function encodeScheduleAction( workflowType: { name: action.workflowType, }, - input: { payloads: await encodeToPayloadsWithContext(dataConverter, context, action.args) }, + input: { payloads: await encodeToPayloadsWithContext(dataConverter, context, action.args, action.typeHints?.inputTypes) }, taskQueue: { kind: temporal.api.enums.v1.TaskQueueKind.TASK_QUEUE_KIND_NORMAL, name: action.taskQueue, @@ -273,6 +279,7 @@ export async function encodeScheduleAction( workflowRunTimeout: msOptionalToTs(action.workflowRunTimeout), workflowTaskTimeout: msOptionalToTs(action.workflowTaskTimeout), retryPolicy: action.retry ? compileRetryPolicy(action.retry) : undefined, + // THOMAS - no support for memo yet memo: action.memo ? { fields: await encodeMapToPayloads(dataConverter, action.memo, context) } : undefined, searchAttributes: action.searchAttributes || action.typedSearchAttributes diff --git a/packages/client/src/schedule-types.ts b/packages/client/src/schedule-types.ts index 0c384b883e..fbc36f3666 100644 --- a/packages/client/src/schedule-types.ts +++ b/packages/client/src/schedule-types.ts @@ -6,6 +6,7 @@ import type { Workflow, TypedSearchAttributes, SearchAttributePair, + PayloadTypeHints, } from '@temporalio/common'; import { makeProtoEnumConverters } from '@temporalio/common/lib/internal-workflow'; import type { temporal } from '@temporalio/proto'; @@ -792,6 +793,7 @@ export type ScheduleOptionsStartWorkflowAction = { | 'workflowTaskTimeout' | 'staticDetails' | 'staticSummary' + | 'typeHints' > & { /** * Workflow id to use when starting. Assign a meaningful business id. @@ -838,7 +840,11 @@ export type CompiledScheduleAction = Replace< workflowType: string; args: unknown[]; } ->; + > & { + // THOMAS - a bit hacky, needed due to CompiledScheduleAction being a derived + // type of ScheduleDescriptionAction for some reason + typeHints?: PayloadTypeHints; +}; /** * Policy for overlapping Actions. diff --git a/packages/client/src/workflow-client.ts b/packages/client/src/workflow-client.ts index 8107e4fd16..a16aaa6677 100644 --- a/packages/client/src/workflow-client.ts +++ b/packages/client/src/workflow-client.ts @@ -1265,7 +1265,7 @@ export class WorkflowClient extends BaseClient { workflowIdReusePolicy: encodeWorkflowIdReusePolicy(options.workflowIdReusePolicy), workflowIdConflictPolicy: encodeWorkflowIdConflictPolicy(options.workflowIdConflictPolicy), workflowType: { name: workflowType }, - input: { payloads: await encodeToPayloadsWithContext(dataConverter, context, options.args) }, + input: { payloads: await encodeToPayloadsWithContext(dataConverter, context, options.args, options.typeHints?.inputTypes) }, signalName, signalInput: { payloads: await encodeToPayloadsWithContext(dataConverter, context, signalArgs) }, taskQueue: { @@ -1277,6 +1277,7 @@ export class WorkflowClient extends BaseClient { workflowTaskTimeout: options.workflowTaskTimeout, workflowStartDelay: options.startDelay, retryPolicy: options.retry ? compileRetryPolicy(options.retry) : undefined, + // THOMAS - memo type hints not supported yet memo: options.memo ? { fields: await encodeMapToPayloads(dataConverter, options.memo, context) } : undefined, searchAttributes: options.searchAttributes || options.typedSearchAttributes diff --git a/packages/common/src/workflow-options.ts b/packages/common/src/workflow-options.ts index 367dfcb093..7eaa9fc088 100644 --- a/packages/common/src/workflow-options.ts +++ b/packages/common/src/workflow-options.ts @@ -277,20 +277,7 @@ export interface WorkflowTypeOptions { staticOptions?: WorkflowStaticOptions; } -// THOMAS - Maybe get rid of this -export function extractWorkflowType( - workflowTypeOrFunc: string | T | WorkflowFunctionWithOptions -): string { - if (typeof workflowTypeOrFunc === 'string') return workflowTypeOrFunc as string; - if (typeof workflowTypeOrFunc === 'function') { - if (workflowTypeOrFunc?.name) return workflowTypeOrFunc.name; - throw new TypeError('Invalid workflow type: the workflow function is anonymous'); - } - throw new TypeError( - `Invalid workflow type: expected either a string or a function, got '${typeof workflowTypeOrFunc}'` - ); -} - +// THOMAS - rename (function and return type) export function extractWorkflowTypeAndConfig(workflowTypeOrFunc: string | T): WorkflowTypeOptions { if (typeof workflowTypeOrFunc === 'string') { return { type: workflowTypeOrFunc }; diff --git a/packages/workflow/src/workflow.ts b/packages/workflow/src/workflow.ts index c69429fbd7..b62f170225 100644 --- a/packages/workflow/src/workflow.ts +++ b/packages/workflow/src/workflow.ts @@ -24,7 +24,7 @@ import { compilePriority, encodeActivityCancellationType, encodeWorkflowIdReusePolicy, - extractWorkflowType, + extractWorkflowTypeAndConfig, HandlerUnfinishedPolicy, mapToPayloads, encodeInitialVersioningBehavior, @@ -435,7 +435,7 @@ function startChildWorkflowExecutionNextHandler({ seq, workflowId, workflowType, - input: toPayloadsWithContext(activator.payloadConverter, context, options.args), + input: toPayloadsWithContext(activator.payloadConverter, context, options.args, options.typeHints?.inputTypes), retryPolicy: options.retry ? compileRetryPolicy(options.retry) : undefined, taskQueue: options.taskQueue || activator.info.taskQueue, workflowExecutionTimeout: msOptionalToTs(options.workflowExecutionTimeout), @@ -451,6 +451,7 @@ function startChildWorkflowExecutionNextHandler({ options.searchAttributes || options.typedSearchAttributes ? { indexedFields: encodeUnifiedSearchAttributes(options.searchAttributes, options.typedSearchAttributes) } : undefined, + // THOMAS - memo type hints not supported yet! memo: options.memo && mapToPayloads(activator.payloadConverter, options.memo, context), versioningIntent: versioningIntentToProto(options.versioningIntent), priority: options.priority ? compilePriority(options.priority) : undefined, @@ -875,7 +876,13 @@ export async function startChild( 'Workflow.startChild(...) may only be used from a Workflow Execution. Consider using Client.workflow.start(...) instead.)' ); const optionsWithDefaults = addDefaultWorkflowOptions(options ?? ({} as any)); - const workflowType = extractWorkflowType(workflowTypeOrFunc); + const workflowDefinitionConfig = extractWorkflowTypeAndConfig(workflowTypeOrFunc); + // Resolve type hints (type hints provided at call site take precedence) + const typeHints = optionsWithDefaults?.typeHints ?? workflowDefinitionConfig.staticOptions?.typeHints; + const workflowOptions = { + ...optionsWithDefaults, + typeHints, + }; const execute = composeInterceptors( activator.interceptors.outbound, 'startChildWorkflowExecution', @@ -883,14 +890,14 @@ export async function startChild( ); const [started, completed] = await execute({ seq: activator.nextSeqs.childWorkflow++, - options: optionsWithDefaults, + options: workflowOptions, headers: {}, - workflowType, + workflowType: workflowDefinitionConfig.type, }); const firstExecutionRunId = await started; return { - workflowId: optionsWithDefaults.workflowId, + workflowId: workflowOptions.workflowId, firstExecutionRunId, async result(): Promise> { return (await completed) as any; @@ -906,7 +913,7 @@ export async function startChild( args, target: { type: 'child', - childWorkflowId: optionsWithDefaults.workflowId, + childWorkflowId: workflowOptions.workflowId, }, headers: {}, }); @@ -976,7 +983,13 @@ export async function executeChild( 'Workflow.executeChild(...) may only be used from a Workflow Execution. Consider using Client.workflow.execute(...) instead.' ); const optionsWithDefaults = addDefaultWorkflowOptions(options ?? ({} as any)); - const workflowType = extractWorkflowType(workflowTypeOrFunc); + const workflowDefinitionConfig = extractWorkflowTypeAndConfig(workflowTypeOrFunc); + // Resolve type hints (type hints provided at call site take precedence) + const typeHints = optionsWithDefaults?.typeHints ?? workflowDefinitionConfig.staticOptions?.typeHints; + const workflowOptions = { + ...optionsWithDefaults, + typeHints, + }; const execute = composeInterceptors( activator.interceptors.outbound, 'startChildWorkflowExecution', @@ -984,9 +997,9 @@ export async function executeChild( ); const execPromise = execute({ seq: activator.nextSeqs.childWorkflow++, - options: optionsWithDefaults, + options: workflowOptions, headers: {}, - workflowType, + workflowType: workflowDefinitionConfig.type, }); untrackPromise(execPromise); const completedPromise = execPromise.then(([_started, completed]) => completed); @@ -1074,7 +1087,7 @@ export function makeContinueAsNewFunc( const { headers, args, options } = input; throw new ContinueAsNew({ workflowType: options.workflowType, - arguments: toPayloadsWithContext(activator.payloadConverter, context, args), + arguments: toPayloadsWithContext(activator.payloadConverter, context, args, activator.typeHints?.inputTypes), headers, taskQueue: options.taskQueue, memo: options.memo && mapToPayloads(activator.payloadConverter, options.memo, context), From 586a561187618dd78b8b2bd4081be67518d5810f Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 6 Jul 2026 07:43:12 -0400 Subject: [PATCH 06/14] initial CAN input wiring --- packages/workflow/src/interfaces.ts | 4 ++++ packages/workflow/src/workflow.ts | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index 2d8abdbe78..933188f549 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -16,6 +16,7 @@ import type { VersioningBehavior, InitialVersioningBehavior, SuggestContinueAsNewReason, + PayloadTypeHints, } from '@temporalio/common'; import { SymbolBasedInstanceOfError } from '@temporalio/common/lib/type-helpers'; import { makeProtoEnumConverters } from '@temporalio/common/lib/internal-workflow/enums-helpers'; @@ -364,6 +365,9 @@ export interface ContinueAsNewOptions { * @experimental Versioning semantics with continue-as-new are experimental and may change in the future. */ initialVersioningBehavior?: InitialVersioningBehavior; + + /** typeHints input in case you CAN to a different workflow */ + typeHints?: PayloadTypeHints } /** diff --git a/packages/workflow/src/workflow.ts b/packages/workflow/src/workflow.ts index b62f170225..c59c42f759 100644 --- a/packages/workflow/src/workflow.ts +++ b/packages/workflow/src/workflow.ts @@ -1081,15 +1081,22 @@ export function makeContinueAsNewFunc( ...rest, }; + let resolvedTypeHints = options?.typeHints; + // If no hints were provided, reuse current workflow hints only for same-type continue-as-new. + if (resolvedTypeHints == null && requiredOptions.workflowType === info.workflowType) { + resolvedTypeHints = activator.typeHints; + } + return (...args: Parameters): Promise => { const context = currentWorkflowSerializationContext(info); const fn = composeInterceptors(activator.interceptors.outbound, 'continueAsNew', async (input) => { const { headers, args, options } = input; throw new ContinueAsNew({ workflowType: options.workflowType, - arguments: toPayloadsWithContext(activator.payloadConverter, context, args, activator.typeHints?.inputTypes), + arguments: toPayloadsWithContext(activator.payloadConverter, context, args, resolvedTypeHints?.inputTypes), headers, taskQueue: options.taskQueue, + // THOMAS - memo type hints not support yet memo: options.memo && mapToPayloads(activator.payloadConverter, options.memo, context), searchAttributes: options.searchAttributes || options.typedSearchAttributes From f41f2b1cbd79fec32f1311f84b20405a6e783543 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 6 Jul 2026 07:58:42 -0400 Subject: [PATCH 07/14] add type hint on decode path --- packages/common/src/converter/payload-converter.ts | 5 +++-- packages/common/src/internal-non-workflow/codec-helpers.ts | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/common/src/converter/payload-converter.ts b/packages/common/src/converter/payload-converter.ts index 0bce34953e..b1d6ffe614 100644 --- a/packages/common/src/converter/payload-converter.ts +++ b/packages/common/src/converter/payload-converter.ts @@ -111,7 +111,8 @@ export function fromPayloadsAtIndex( converter: PayloadConverter, index: number, payloads?: Payload[] | null, - context?: SerializationContext + context?: SerializationContext, + typeHint?: TypeHint ): T { // To make adding arguments a backwards compatible change if (payloads === undefined || payloads === null || index >= payloads.length) { @@ -121,7 +122,7 @@ export function fromPayloadsAtIndex( if (!payload) { return undefined as any; } - return converter.fromPayload(payload, context); + return converter.fromPayload(payload, context, typeHint); } /** diff --git a/packages/common/src/internal-non-workflow/codec-helpers.ts b/packages/common/src/internal-non-workflow/codec-helpers.ts index da9b0339e9..1958d74b63 100644 --- a/packages/common/src/internal-non-workflow/codec-helpers.ts +++ b/packages/common/src/internal-non-workflow/codec-helpers.ts @@ -144,14 +144,16 @@ export async function decodeFromPayloadsAtIndex( converter: LoadedDataConverter, index: number, payloads?: Payload[] | null, - context?: SerializationContext + context?: SerializationContext, + typeHint?: TypeHint ): Promise { const { payloadConverter, payloadCodecs } = converter; return await fromPayloadsAtIndex( payloadConverter, index, await decodeOptional(payloadCodecs, payloads, context), - context + context, + typeHint ); } From 00f04563660b0cdb09c16d7905798e1e40ddc53b Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 6 Jul 2026 08:23:21 -0400 Subject: [PATCH 08/14] wire output type to workflow result (still missing passing workflow definition output type to workflow result) --- packages/client/src/workflow-client.ts | 15 ++++++++++++++- .../src/internal-non-workflow/codec-helpers.ts | 5 +++-- packages/workflow/src/internals.ts | 2 +- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/client/src/workflow-client.ts b/packages/client/src/workflow-client.ts index a16aaa6677..c43779fb4a 100644 --- a/packages/client/src/workflow-client.ts +++ b/packages/client/src/workflow-client.ts @@ -11,6 +11,7 @@ import type { WorkflowResultType, WorkflowIdConflictPolicy, WorkflowSerializationContext, + TypeHint, } from '@temporalio/common'; import { CancelledFailure, @@ -343,6 +344,16 @@ export interface WorkflowResultOptions { * @default true */ followRuns?: boolean; + + /** + * Type hint used to decode the Workflow result. + * + * This is only needed when getting a result from an existing Workflow handle or + * when overriding definition-supplied hints. + * + * @experimental + */ + typeHint?: TypeHint } /** @@ -804,6 +815,7 @@ export class WorkflowClient extends BaseClient { return await this.result(workflowId, undefined, { ...options, followRuns: options.followRuns ?? true, + typeHint: options.typeHints?.outputType }); } @@ -860,7 +872,8 @@ export class WorkflowClient extends BaseClient { const [result] = await decodeArrayFromPayloads( dataConverter, ev.workflowExecutionCompletedEventAttributes.result?.payloads, - context + context, + [opts?.typeHint] ); return result as any; } else if (ev.workflowExecutionFailedEventAttributes) { diff --git a/packages/common/src/internal-non-workflow/codec-helpers.ts b/packages/common/src/internal-non-workflow/codec-helpers.ts index 1958d74b63..8dba67f8c8 100644 --- a/packages/common/src/internal-non-workflow/codec-helpers.ts +++ b/packages/common/src/internal-non-workflow/codec-helpers.ts @@ -131,10 +131,11 @@ export async function encodeToPayload( export async function decodeArrayFromPayloads( converter: LoadedDataConverter, payloads?: Payload[] | null, - context?: SerializationContext + context?: SerializationContext, + typeHints?: readonly TypeHint[] ): Promise { const { payloadConverter, payloadCodecs } = converter; - return arrayFromPayloads(payloadConverter, await decodeOptional(payloadCodecs, payloads, context), context); + return arrayFromPayloads(payloadConverter, await decodeOptional(payloadCodecs, payloads, context), context, typeHints); } /** diff --git a/packages/workflow/src/internals.ts b/packages/workflow/src/internals.ts index 790a4c68aa..010fdefac3 100644 --- a/packages/workflow/src/internals.ts +++ b/packages/workflow/src/internals.ts @@ -1421,7 +1421,7 @@ export class Activator implements ActivationHandler { this.pushCommand( { completeWorkflowExecution: { - result: this.payloadConverter.toPayload(result, context), + result: this.payloadConverter.toPayload(result, context, this.typeHints?.outputType), }, }, true From 35b3e841246d7d57e669c7c916d8f15da42d026a Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 6 Jul 2026 08:51:26 -0400 Subject: [PATCH 09/14] update extractTypeAndConfig helper, move type hint resolution logic there --- packages/client/src/schedule-helpers.ts | 27 +++++++++---------- packages/client/src/workflow-client.ts | 35 ++++++++++++------------- packages/common/src/workflow-options.ts | 24 ++++++++--------- packages/workflow/src/workflow.ts | 18 +++++++------ 4 files changed, 50 insertions(+), 54 deletions(-) diff --git a/packages/client/src/schedule-helpers.ts b/packages/client/src/schedule-helpers.ts index 656424d74c..5d89614dc7 100644 --- a/packages/client/src/schedule-helpers.ts +++ b/packages/client/src/schedule-helpers.ts @@ -198,16 +198,15 @@ export function decodeOptionalStructuredCalendarSpecs( } export function compileScheduleOptions(options: ScheduleOptions): CompiledScheduleOptions { - const workflowDefinitionConfig = extractWorkflowTypeAndConfig(options.action.workflowType); - // Resolve type hints (type hints provided at call site take precedence) - const typeHints = options?.action.typeHints ?? workflowDefinitionConfig.staticOptions?.typeHints; + const { action } = options; + const { type: workflowType, typeHints } = extractWorkflowTypeAndConfig(action.workflowType, action.typeHints); return { ...options, action: { - ...options.action, - workflowId: options.action.workflowId ?? `${options.scheduleId}-workflow`, - workflowType: workflowDefinitionConfig.type, - args: (options.action.args ?? []) as unknown[], + ...action, + workflowId: action.workflowId ?? `${options.scheduleId}-workflow`, + workflowType, + args: (action.args ?? []) as unknown[], typeHints, }, }; @@ -217,17 +216,15 @@ export function compileUpdatedScheduleOptions( scheduleId: string, options: ScheduleUpdateOptions ): CompiledScheduleUpdateOptions { - const workflowTypeOrFunc = options.action.workflowType; - const workflowDefinitionConfig = extractWorkflowTypeAndConfig(workflowTypeOrFunc); - // Resolve type hints (type hints provided at call site take precedence) - const typeHints = options?.action.typeHints ?? workflowDefinitionConfig.staticOptions?.typeHints; + const { action } = options; + const { type: workflowType, typeHints } = extractWorkflowTypeAndConfig(action.workflowType, action.typeHints); return { ...options, action: { - ...options.action, - workflowId: options.action.workflowId ?? `${scheduleId}-workflow`, - workflowType: workflowDefinitionConfig.type, - args: (options.action.args ?? []) as unknown[], + ...action, + workflowId: action.workflowId ?? `${scheduleId}-workflow`, + workflowType, + args: (action.args ?? []) as unknown[], typeHints, }, }; diff --git a/packages/client/src/workflow-client.ts b/packages/client/src/workflow-client.ts index c43779fb4a..bc8f05b1b9 100644 --- a/packages/client/src/workflow-client.ts +++ b/packages/client/src/workflow-client.ts @@ -353,7 +353,7 @@ export interface WorkflowResultOptions { * * @experimental */ - typeHint?: TypeHint + typeHint?: TypeHint; } /** @@ -548,10 +548,8 @@ export class WorkflowClient extends BaseClient { options: WorkflowStartOptions, interceptors: WorkflowClientInterceptor[] ): Promise { - const workflowDefinitionConfig = extractWorkflowTypeAndConfig(workflowTypeOrFunc); + const { type: workflowType, typeHints } = extractWorkflowTypeAndConfig(workflowTypeOrFunc, options.typeHints); assertRequiredWorkflowOptions(options); - // Resolve type hints (type hints provided at call site take precedence) - const typeHints = options.typeHints ?? workflowDefinitionConfig.staticOptions?.typeHints; const workflowOptions = { ...options, typeHints, @@ -568,7 +566,7 @@ export class WorkflowClient extends BaseClient { return startWithDetails({ options: compiledOptions, headers: {}, - workflowType: workflowDefinitionConfig.type, + workflowType, }); } @@ -577,11 +575,9 @@ export class WorkflowClient extends BaseClient { options: WithWorkflowArgs>, interceptors: WorkflowClientInterceptor[] ): Promise { - const workflowDefinitionConfig = extractWorkflowTypeAndConfig(workflowTypeOrFunc); const { signal, signalArgs, ...rest } = options; + const { type: workflowType, typeHints } = extractWorkflowTypeAndConfig(workflowTypeOrFunc, rest.typeHints); assertRequiredWorkflowOptions(rest); - // Resolve type hints (type hints provided at call site take precedence) - const typeHints = rest.typeHints ?? workflowDefinitionConfig.staticOptions?.typeHints; const workflowOptions = { ...rest, typeHints, @@ -596,7 +592,7 @@ export class WorkflowClient extends BaseClient { return signalWithStart({ options: compiledOptions, headers: {}, - workflowType: workflowDefinitionConfig.type, + workflowType, signalName: typeof signal === 'string' ? signal : signal.name, signalArgs: signalArgs ?? [], }); @@ -742,17 +738,18 @@ export class WorkflowClient extends BaseClient { throw new Error('This WithStartWorkflowOperation instance has already been executed.'); } startWorkflowOperation[withStartWorkflowOperationUsed] = true; - const workflowDefinitionConfig = extractWorkflowTypeAndConfig(workflowTypeOrFunc); + const { type: workflowType, typeHints } = extractWorkflowTypeAndConfig( + workflowTypeOrFunc, + workflowOptions.typeHints + ); assertRequiredWorkflowOptions(workflowOptions); - // Resolve type hints (type hints provided at call site take precedence) - const typeHints = workflowOptions.typeHints ?? workflowDefinitionConfig.staticOptions?.typeHints; const resolvedWorkflowOptions = { ...workflowOptions, typeHints, }; const startUpdateWithStartInput: WorkflowStartUpdateWithStartInput = { - workflowType: workflowDefinitionConfig.type, + workflowType, workflowStartOptions: compileWorkflowOptions(ensureArgs(resolvedWorkflowOptions)), workflowStartHeaders: {}, updateName: typeof updateDef === 'string' ? updateDef : updateDef.name, @@ -811,11 +808,12 @@ export class WorkflowClient extends BaseClient { ): Promise> { const { workflowId } = options; const interceptors = this.getOrMakeInterceptors(workflowId); - await this._start(workflowTypeOrFunc, options, interceptors); + const { typeHints } = extractWorkflowTypeAndConfig(workflowTypeOrFunc, options.typeHints); + await this._start(workflowTypeOrFunc, { ...options, typeHints }, interceptors); return await this.result(workflowId, undefined, { ...options, followRuns: options.followRuns ?? true, - typeHint: options.typeHints?.outputType + typeHint: typeHints?.outputType, }); } @@ -869,13 +867,14 @@ export class WorkflowClient extends BaseClient { } // Note that we can only return one value from our workflow function in JS. // Ignore any other payloads in result - const [result] = await decodeArrayFromPayloads( + const result = await decodeFromPayloadsAtIndex>( dataConverter, + 0, ev.workflowExecutionCompletedEventAttributes.result?.payloads, context, - [opts?.typeHint] + opts?.typeHint ); - return result as any; + return result; } else if (ev.workflowExecutionFailedEventAttributes) { if (followRuns && ev.workflowExecutionFailedEventAttributes.newExecutionRunId) { execution.runId = ev.workflowExecutionFailedEventAttributes.newExecutionRunId; diff --git a/packages/common/src/workflow-options.ts b/packages/common/src/workflow-options.ts index 7eaa9fc088..070ab15efc 100644 --- a/packages/common/src/workflow-options.ts +++ b/packages/common/src/workflow-options.ts @@ -5,11 +5,7 @@ import type { Duration } from './time'; import { makeProtoEnumConverters } from './internal-workflow'; import type { SearchAttributePair, SearchAttributes, TypedSearchAttributes } from './search-attributes'; import type { Priority } from './priority'; -import { - type WorkflowStaticOptions, - type WorkflowFunctionWithOptions, - isWorkflowFunctionWithStaticOptions, -} from './workflow-definition-options'; +import { isWorkflowFunctionWithStaticOptions } from './workflow-definition-options'; import { PayloadTypeHints } from './type-hints'; /** * Defines what happens when trying to start a Workflow with the same ID as a *Closed* Workflow. @@ -274,24 +270,26 @@ export type CommonWorkflowOptions = BaseWorkflowOptions & WorkflowDurationOption export interface WorkflowTypeOptions { type: string; - staticOptions?: WorkflowStaticOptions; + typeHints?: PayloadTypeHints; } -// THOMAS - rename (function and return type) -export function extractWorkflowTypeAndConfig(workflowTypeOrFunc: string | T): WorkflowTypeOptions { +export function extractWorkflowTypeAndConfig( + workflowTypeOrFunc: string | T, + callSiteTypeHints?: PayloadTypeHints +): WorkflowTypeOptions { if (typeof workflowTypeOrFunc === 'string') { - return { type: workflowTypeOrFunc }; + return { type: workflowTypeOrFunc, typeHints: callSiteTypeHints }; } if (typeof workflowTypeOrFunc === 'function') { if (!workflowTypeOrFunc.name) { throw new TypeError('Invalid workflow type: the workflow function is anonymous'); } - const staticOptions = isWorkflowFunctionWithStaticOptions(workflowTypeOrFunc) - ? workflowTypeOrFunc.staticOptions - : undefined; + const resolvedTypeHints = isWorkflowFunctionWithStaticOptions(workflowTypeOrFunc) + ? workflowTypeOrFunc.staticOptions.typeHints + : callSiteTypeHints; return { type: workflowTypeOrFunc.name, - staticOptions, + typeHints: resolvedTypeHints, }; } throw new TypeError( diff --git a/packages/workflow/src/workflow.ts b/packages/workflow/src/workflow.ts index c59c42f759..166fe3202c 100644 --- a/packages/workflow/src/workflow.ts +++ b/packages/workflow/src/workflow.ts @@ -876,9 +876,10 @@ export async function startChild( 'Workflow.startChild(...) may only be used from a Workflow Execution. Consider using Client.workflow.start(...) instead.)' ); const optionsWithDefaults = addDefaultWorkflowOptions(options ?? ({} as any)); - const workflowDefinitionConfig = extractWorkflowTypeAndConfig(workflowTypeOrFunc); - // Resolve type hints (type hints provided at call site take precedence) - const typeHints = optionsWithDefaults?.typeHints ?? workflowDefinitionConfig.staticOptions?.typeHints; + const { type: workflowType, typeHints } = extractWorkflowTypeAndConfig( + workflowTypeOrFunc, + optionsWithDefaults.typeHints + ); const workflowOptions = { ...optionsWithDefaults, typeHints, @@ -892,7 +893,7 @@ export async function startChild( seq: activator.nextSeqs.childWorkflow++, options: workflowOptions, headers: {}, - workflowType: workflowDefinitionConfig.type, + workflowType, }); const firstExecutionRunId = await started; @@ -983,9 +984,10 @@ export async function executeChild( 'Workflow.executeChild(...) may only be used from a Workflow Execution. Consider using Client.workflow.execute(...) instead.' ); const optionsWithDefaults = addDefaultWorkflowOptions(options ?? ({} as any)); - const workflowDefinitionConfig = extractWorkflowTypeAndConfig(workflowTypeOrFunc); - // Resolve type hints (type hints provided at call site take precedence) - const typeHints = optionsWithDefaults?.typeHints ?? workflowDefinitionConfig.staticOptions?.typeHints; + const { type: workflowType, typeHints } = extractWorkflowTypeAndConfig( + workflowTypeOrFunc, + optionsWithDefaults.typeHints + ); const workflowOptions = { ...optionsWithDefaults, typeHints, @@ -999,7 +1001,7 @@ export async function executeChild( seq: activator.nextSeqs.childWorkflow++, options: workflowOptions, headers: {}, - workflowType: workflowDefinitionConfig.type, + workflowType, }); untrackPromise(execPromise); const completedPromise = execPromise.then(([_started, completed]) => completed); From c235e6e64b1becd503f44b4f64f90f98af917f1d Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 6 Jul 2026 09:27:05 -0400 Subject: [PATCH 10/14] add validation (no call-site hints if workflow definition provided, must use definition options) --- packages/client/src/workflow-client.ts | 17 +++++++++-------- packages/common/src/workflow-options.ts | 13 ++++++++++--- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/client/src/workflow-client.ts b/packages/client/src/workflow-client.ts index bc8f05b1b9..fca4e5bda1 100644 --- a/packages/client/src/workflow-client.ts +++ b/packages/client/src/workflow-client.ts @@ -11,6 +11,7 @@ import type { WorkflowResultType, WorkflowIdConflictPolicy, WorkflowSerializationContext, + WorkflowTypeOptions, TypeHint, } from '@temporalio/common'; import { @@ -544,15 +545,14 @@ export class WorkflowClient extends BaseClient { } protected async _start( - workflowTypeOrFunc: string | T, + workflowTypeOptions: WorkflowTypeOptions, options: WorkflowStartOptions, interceptors: WorkflowClientInterceptor[] ): Promise { - const { type: workflowType, typeHints } = extractWorkflowTypeAndConfig(workflowTypeOrFunc, options.typeHints); assertRequiredWorkflowOptions(options); const workflowOptions = { ...options, - typeHints, + typeHints: workflowTypeOptions.typeHints, }; const compiledOptions = compileWorkflowOptions(ensureArgs(workflowOptions)); const adaptedInterceptors = interceptors.map((i) => adaptWorkflowClientInterceptor(i)); @@ -566,7 +566,7 @@ export class WorkflowClient extends BaseClient { return startWithDetails({ options: compiledOptions, headers: {}, - workflowType, + workflowType: workflowTypeOptions.type, }); } @@ -609,7 +609,8 @@ export class WorkflowClient extends BaseClient { ): Promise> { const { workflowId } = options; const interceptors = this.getOrMakeInterceptors(workflowId); - const wfStartOutput = await this._start(workflowTypeOrFunc, { ...options, workflowId }, interceptors); + const workflowTypeOptions = extractWorkflowTypeAndConfig(workflowTypeOrFunc, options.typeHints); + const wfStartOutput = await this._start(workflowTypeOptions, { ...options, workflowId }, interceptors); // runId is not used in handles created with `start*` calls because these // handles should allow interacting with the workflow if it continues as new. const baseHandle = this._createWorkflowHandle({ @@ -808,12 +809,12 @@ export class WorkflowClient extends BaseClient { ): Promise> { const { workflowId } = options; const interceptors = this.getOrMakeInterceptors(workflowId); - const { typeHints } = extractWorkflowTypeAndConfig(workflowTypeOrFunc, options.typeHints); - await this._start(workflowTypeOrFunc, { ...options, typeHints }, interceptors); + const workflowTypeOptions = extractWorkflowTypeAndConfig(workflowTypeOrFunc, options.typeHints); + await this._start(workflowTypeOptions, options, interceptors); return await this.result(workflowId, undefined, { ...options, followRuns: options.followRuns ?? true, - typeHint: typeHints?.outputType, + typeHint: workflowTypeOptions.typeHints?.outputType, }); } diff --git a/packages/common/src/workflow-options.ts b/packages/common/src/workflow-options.ts index 070ab15efc..234d9175ca 100644 --- a/packages/common/src/workflow-options.ts +++ b/packages/common/src/workflow-options.ts @@ -284,12 +284,19 @@ export function extractWorkflowTypeAndConfig( if (!workflowTypeOrFunc.name) { throw new TypeError('Invalid workflow type: the workflow function is anonymous'); } - const resolvedTypeHints = isWorkflowFunctionWithStaticOptions(workflowTypeOrFunc) + if (callSiteTypeHints !== undefined) { + throw new TypeError( + 'Workflow type hints cannot be supplied at the call site when using a workflow function. ' + + 'Use defineWorkflowOptions(..., { staticOptions: { typeHints } }) on the workflow function instead, ' + + 'or pass the workflow type as a string.' + ); + } + const definitionTypeHints = isWorkflowFunctionWithStaticOptions(workflowTypeOrFunc) ? workflowTypeOrFunc.staticOptions.typeHints - : callSiteTypeHints; + : undefined; return { type: workflowTypeOrFunc.name, - typeHints: resolvedTypeHints, + typeHints: definitionTypeHints, }; } throw new TypeError( From 03049fa682f12f0f006d5631c833c95aa7d47095 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 6 Jul 2026 09:35:53 -0400 Subject: [PATCH 11/14] add basic unit tests for definition/call-site type hint validation --- packages/test/src/test-type-hints.ts | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/test/src/test-type-hints.ts b/packages/test/src/test-type-hints.ts index 5c4bfe8022..ec76c9edd0 100644 --- a/packages/test/src/test-type-hints.ts +++ b/packages/test/src/test-type-hints.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'crypto'; import { Client } from '@temporalio/client'; +import { extractWorkflowTypeAndConfig } from '@temporalio/common'; import type { PayloadTypeHints } from '@temporalio/common'; import { workflowInterceptorModules } from '@temporalio/testing'; import { bundleWorkflowCode } from '@temporalio/worker'; @@ -77,6 +78,54 @@ function makeClient(env: TestWorkflowEnvironment): Client { }); } +const inputHint = { kind: 'input' }; +const outputHint = { kind: 'output' }; +const callSiteTypeHints: PayloadTypeHints = { inputTypes: [inputHint], outputType: outputHint }; + +async function workflowWithoutDefinitionHints(): Promise {} + +async function workflowWithDefinitionHints(): Promise {} +Object.assign(workflowWithDefinitionHints, { + staticOptions: { + typeHints: callSiteTypeHints, + }, +}); + +test('extractWorkflowTypeAndConfig allows call-site type hints for string workflow types', (t) => { + t.deepEqual(extractWorkflowTypeAndConfig('workflow', callSiteTypeHints), { + type: 'workflow', + typeHints: callSiteTypeHints, + }); +}); + +test('extractWorkflowTypeAndConfig resolves definition type hints for workflow functions', (t) => { + t.deepEqual(extractWorkflowTypeAndConfig(workflowWithDefinitionHints), { + type: 'workflowWithDefinitionHints', + typeHints: callSiteTypeHints, + }); +}); + +test('extractWorkflowTypeAndConfig allows workflow functions without type hints', (t) => { + t.deepEqual(extractWorkflowTypeAndConfig(workflowWithoutDefinitionHints), { + type: 'workflowWithoutDefinitionHints', + typeHints: undefined, + }); +}); + +test('extractWorkflowTypeAndConfig rejects call-site type hints for workflow functions without definition hints', (t) => { + t.throws(() => extractWorkflowTypeAndConfig(workflowWithoutDefinitionHints, callSiteTypeHints), { + instanceOf: TypeError, + message: /Workflow type hints cannot be supplied at the call site when using a workflow function/, + }); +}); + +test('extractWorkflowTypeAndConfig rejects call-site type hints for workflow functions with definition hints', (t) => { + t.throws(() => extractWorkflowTypeAndConfig(workflowWithDefinitionHints, callSiteTypeHints), { + instanceOf: TypeError, + message: /Workflow type hints cannot be supplied at the call site when using a workflow function/, + }); +}); + test('workflow start uses definition-supplied input type hints', async (t) => { const h = configurableHelpers(t, t.context.workflowBundle, t.context.env); const client = makeClient(t.context.env); From ad9a4ae913a6c9205bbe1e8f0525da411a65b2c6 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 6 Jul 2026 10:40:16 -0400 Subject: [PATCH 12/14] basic integration tests for workflow start/execute/CAN for definition-supplied type hints --- packages/client/src/workflow-client.ts | 2 + packages/test/src/test-type-hints.ts | 136 +++++++++------------- packages/test/src/workflows/type-hints.ts | 70 +++++++++++ 3 files changed, 125 insertions(+), 83 deletions(-) create mode 100644 packages/test/src/workflows/type-hints.ts diff --git a/packages/client/src/workflow-client.ts b/packages/client/src/workflow-client.ts index fca4e5bda1..b7b5202072 100644 --- a/packages/client/src/workflow-client.ts +++ b/packages/client/src/workflow-client.ts @@ -620,6 +620,7 @@ export class WorkflowClient extends BaseClient { runIdForResult: wfStartOutput.runId, interceptors, followRuns: options.followRuns ?? true, + typeHint: workflowTypeOptions.typeHints?.outputType, }); return { ...baseHandle, @@ -1655,6 +1656,7 @@ export class WorkflowClient extends BaseClient { runIdForResult: runId ?? options?.firstExecutionRunId, interceptors, followRuns: options?.followRuns ?? true, + typeHint: options?.typeHint, }); } diff --git a/packages/test/src/test-type-hints.ts b/packages/test/src/test-type-hints.ts index ec76c9edd0..d31fae5b4d 100644 --- a/packages/test/src/test-type-hints.ts +++ b/packages/test/src/test-type-hints.ts @@ -1,10 +1,8 @@ import { randomUUID } from 'crypto'; +import type { ExecutionContext } from 'ava'; import { Client } from '@temporalio/client'; -import { extractWorkflowTypeAndConfig } from '@temporalio/common'; -import type { PayloadTypeHints } from '@temporalio/common'; import { workflowInterceptorModules } from '@temporalio/testing'; import { bundleWorkflowCode } from '@temporalio/worker'; -import { defineWorkflowOptions } from '@temporalio/workflow'; import type { TestWorkflowEnvironment } from './helpers'; import { bundlerOptions } from './helpers'; import type { Context } from './helpers-integration'; @@ -13,45 +11,20 @@ import { createTestWorkflowEnvironment, makeConfigurableEnvironmentTestFn, } from './helpers-integration'; +import { + Order, + Receipt, + workflowTypeHints, + workflowWithTypeHints, +} from './workflows/type-hints'; const converterPath = require.resolve('./payload-converters/type-hints'); const dataConverter = { payloadConverterPath: converterPath }; -class Order { - constructor( - readonly id: string, - readonly totalCents: bigint - ) {} - - summary(): string { - return `${this.id}:${this.totalCents}`; - } -} - -const orderHint = { - toIntermediate(value: Order): unknown { - return { id: value.id, totalCents: value.totalCents.toString() }; - }, - fromIntermediate(value: any): Order { - return new Order(value.id, BigInt(value.totalCents)); - }, -}; - -const typeHints: PayloadTypeHints = { inputTypes: [orderHint] }; - -defineWorkflowOptions(workflowStartWithTypeHint, { - staticOptions: { typeHints }, -}); -export async function workflowStartWithTypeHint(order: Order): Promise<{ - isOrder: boolean; - summary: string; - totalCentsType: string; -}> { - return { - isOrder: order instanceof Order, - summary: order instanceof Order ? order.summary() : 'not-an-order', - totalCentsType: typeof (order as any).totalCents, - }; +function assertReceipt(t: ExecutionContext, receipt: Receipt): void { + t.true(receipt instanceof Receipt); + t.is(receipt.summary(), `order-1:12345`); + t.is(typeof receipt.totalCents, 'bigint'); } const test = makeConfigurableEnvironmentTestFn({ @@ -60,7 +33,7 @@ const test = makeConfigurableEnvironmentTestFn({ const workflowBundle = await bundleWorkflowCode({ ...bundlerOptions, workflowInterceptorModules: [...workflowInterceptorModules], - workflowsPath: __filename, + workflowsPath: require.resolve('./workflows/type-hints'), payloadConverterPath: converterPath, }); return { env, workflowBundle }; @@ -78,70 +51,67 @@ function makeClient(env: TestWorkflowEnvironment): Client { }); } -const inputHint = { kind: 'input' }; -const outputHint = { kind: 'output' }; -const callSiteTypeHints: PayloadTypeHints = { inputTypes: [inputHint], outputType: outputHint }; - -async function workflowWithoutDefinitionHints(): Promise {} +test('workflow definition with call-site type hints is invalid', async (t) => { + const client = makeClient(t.context.env); -async function workflowWithDefinitionHints(): Promise {} -Object.assign(workflowWithDefinitionHints, { - staticOptions: { - typeHints: callSiteTypeHints, - }, + await t.throwsAsync( + client.workflow.execute(workflowWithTypeHints, { + workflowId: `wf-${randomUUID()}`, + taskQueue: 'unused', + args: [new Order('order-1', 12345n)], + typeHints: workflowTypeHints, + }), + { + instanceOf: TypeError, + message: /Workflow type hints cannot be supplied at the call site when using a workflow function/, + } + ); }); -test('extractWorkflowTypeAndConfig allows call-site type hints for string workflow types', (t) => { - t.deepEqual(extractWorkflowTypeAndConfig('workflow', callSiteTypeHints), { - type: 'workflow', - typeHints: callSiteTypeHints, - }); -}); +test('workflow execute uses definition-supplied input and output type hints', async (t) => { + const h = configurableHelpers(t, t.context.workflowBundle, t.context.env); + const client = makeClient(t.context.env); + const worker = await h.createWorker({ dataConverter }); -test('extractWorkflowTypeAndConfig resolves definition type hints for workflow functions', (t) => { - t.deepEqual(extractWorkflowTypeAndConfig(workflowWithDefinitionHints), { - type: 'workflowWithDefinitionHints', - typeHints: callSiteTypeHints, - }); -}); + await worker.runUntil(async () => { + const result = await client.workflow.execute(workflowWithTypeHints, { + workflowId: `wf-${randomUUID()}`, + taskQueue: h.taskQueue, + args: [new Order('order-1', 12345n)], + }); -test('extractWorkflowTypeAndConfig allows workflow functions without type hints', (t) => { - t.deepEqual(extractWorkflowTypeAndConfig(workflowWithoutDefinitionHints), { - type: 'workflowWithoutDefinitionHints', - typeHints: undefined, + assertReceipt(t, result); }); }); -test('extractWorkflowTypeAndConfig rejects call-site type hints for workflow functions without definition hints', (t) => { - t.throws(() => extractWorkflowTypeAndConfig(workflowWithoutDefinitionHints, callSiteTypeHints), { - instanceOf: TypeError, - message: /Workflow type hints cannot be supplied at the call site when using a workflow function/, - }); -}); +test('workflow start and result use definition-supplied input and output type hints', async (t) => { + const h = configurableHelpers(t, t.context.workflowBundle, t.context.env); + const client = makeClient(t.context.env); + const worker = await h.createWorker({ dataConverter }); -test('extractWorkflowTypeAndConfig rejects call-site type hints for workflow functions with definition hints', (t) => { - t.throws(() => extractWorkflowTypeAndConfig(workflowWithDefinitionHints, callSiteTypeHints), { - instanceOf: TypeError, - message: /Workflow type hints cannot be supplied at the call site when using a workflow function/, + await worker.runUntil(async () => { + const handle = await client.workflow.start(workflowWithTypeHints, { + workflowId: `wf-${randomUUID()}`, + taskQueue: h.taskQueue, + args: [new Order('order-1', 12345n)], + }); + + assertReceipt(t, await handle.result()); }); }); -test('workflow start uses definition-supplied input type hints', async (t) => { +test('same-type continue-as-new reuses definition-supplied input and output type hints', async (t) => { const h = configurableHelpers(t, t.context.workflowBundle, t.context.env); const client = makeClient(t.context.env); const worker = await h.createWorker({ dataConverter }); await worker.runUntil(async () => { - const result = await client.workflow.execute(workflowStartWithTypeHint, { + const result = await client.workflow.execute(workflowWithTypeHints, { workflowId: `wf-${randomUUID()}`, taskQueue: h.taskQueue, - args: [new Order('order-1', 12345n)], + args: [new Order('order-1', 12345n, 1)], }); - t.deepEqual(result, { - isOrder: true, - summary: 'order-1:12345', - totalCentsType: 'bigint', - }); + assertReceipt(t, result); }); }); diff --git a/packages/test/src/workflows/type-hints.ts b/packages/test/src/workflows/type-hints.ts new file mode 100644 index 0000000000..121bbacb7a --- /dev/null +++ b/packages/test/src/workflows/type-hints.ts @@ -0,0 +1,70 @@ +import type { PayloadTypeHints } from '@temporalio/common'; +import { continueAsNew, defineWorkflowOptions } from '@temporalio/workflow'; + +export class Order { + constructor( + readonly id: string, + readonly totalCents: bigint, + readonly remainingRuns = 0 + ) {} + + summary(): string { + return `${this.id}:${this.totalCents}:${this.remainingRuns}`; + } +} + +export class Receipt { + constructor( + readonly orderId: string, + readonly totalCents: bigint + ) {} + + summary(): string { + return `${this.orderId}:${this.totalCents}`; + } +} + +export const orderHint = { + toIntermediate(value: Order): unknown { + return { id: value.id, totalCents: value.totalCents.toString(), remainingRuns: value.remainingRuns }; + }, + fromIntermediate(value: any): Order { + return new Order(value.id, BigInt(value.totalCents), value.remainingRuns ?? 0); + }, +}; + +export const receiptHint = { + toIntermediate(value: Receipt): unknown { + return { orderId: value.orderId, totalCents: value.totalCents.toString() }; + }, + fromIntermediate(value: any): Receipt { + return new Receipt(value.orderId, BigInt(value.totalCents)); + }, +}; + +export const workflowTypeHints: PayloadTypeHints = { inputTypes: [orderHint], outputType: receiptHint }; + +function assertOrder(order: Order): void { + if (!(order instanceof Order)) { + throw new Error('Expected Order input'); + } + if (typeof order.totalCents !== 'bigint') { + throw new Error('Expected Order.totalCents to be a bigint'); + } +} + +function makeReceipt(order: Order): Receipt { + assertOrder(order); + return new Receipt(order.id, order.totalCents); +} + +defineWorkflowOptions(workflowWithTypeHints, { + staticOptions: { typeHints: workflowTypeHints }, +}); +export async function workflowWithTypeHints(order: Order): Promise { + assertOrder(order); + if (order.remainingRuns > 0) { + await continueAsNew(new Order(order.id, order.totalCents, order.remainingRuns - 1)); + } + return makeReceipt(order); +} From 7a4c43030ef2146266ec73f6b1de3893c6476c5d Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 6 Jul 2026 10:54:49 -0400 Subject: [PATCH 13/14] add call-site type hint tests for workflow start/result and workflow execute --- packages/test/src/test-type-hints.ts | 34 +++++++++++++++++++++++ packages/test/src/workflows/type-hints.ts | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/test/src/test-type-hints.ts b/packages/test/src/test-type-hints.ts index d31fae5b4d..973efb95ad 100644 --- a/packages/test/src/test-type-hints.ts +++ b/packages/test/src/test-type-hints.ts @@ -100,6 +100,40 @@ test('workflow start and result use definition-supplied input and output type hi }); }); +test('workflow execute uses call-site input and output type hints for string workflow type', async (t) => { + const h = configurableHelpers(t, t.context.workflowBundle, t.context.env); + const client = makeClient(t.context.env); + const worker = await h.createWorker({ dataConverter }); + + await worker.runUntil(async () => { + const result = await client.workflow.execute('workflowWithTypeHints', { + workflowId: `wf-${randomUUID()}`, + taskQueue: h.taskQueue, + args: [new Order('order-1', 12345n)], + typeHints: workflowTypeHints, + }); + + assertReceipt(t, result); + }); +}); + +test('workflow start and result use call-site input and output type hints for string workflow type', async (t) => { + const h = configurableHelpers(t, t.context.workflowBundle, t.context.env); + const client = makeClient(t.context.env); + const worker = await h.createWorker({ dataConverter }); + + await worker.runUntil(async () => { + const handle = await client.workflow.start('workflowWithTypeHints', { + workflowId: `wf-${randomUUID()}`, + taskQueue: h.taskQueue, + args: [new Order('order-1', 12345n)], + typeHints: workflowTypeHints, + }); + + assertReceipt(t, await handle.result()); + }); +}); + test('same-type continue-as-new reuses definition-supplied input and output type hints', async (t) => { const h = configurableHelpers(t, t.context.workflowBundle, t.context.env); const client = makeClient(t.context.env); diff --git a/packages/test/src/workflows/type-hints.ts b/packages/test/src/workflows/type-hints.ts index 121bbacb7a..03b4aada9c 100644 --- a/packages/test/src/workflows/type-hints.ts +++ b/packages/test/src/workflows/type-hints.ts @@ -64,7 +64,7 @@ defineWorkflowOptions(workflowWithTypeHints, { export async function workflowWithTypeHints(order: Order): Promise { assertOrder(order); if (order.remainingRuns > 0) { - await continueAsNew(new Order(order.id, order.totalCents, order.remainingRuns - 1)); + await continueAsNew(new Order(order.id, order.totalCents, order.remainingRuns - 1)); } return makeReceipt(order); } From 68c210e169149ce34a5bc0b4937977f6a0dfab6c Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Mon, 6 Jul 2026 11:56:12 -0400 Subject: [PATCH 14/14] add child workflow integration tests --- packages/test/src/test-type-hints.ts | 56 ++++++++++++++++++++++- packages/test/src/workflows/type-hints.ts | 48 +++++++++++++++++-- packages/workflow/src/internals.ts | 6 ++- packages/workflow/src/workflow.ts | 1 + 4 files changed, 103 insertions(+), 8 deletions(-) diff --git a/packages/test/src/test-type-hints.ts b/packages/test/src/test-type-hints.ts index 973efb95ad..0de733bf06 100644 --- a/packages/test/src/test-type-hints.ts +++ b/packages/test/src/test-type-hints.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'crypto'; import type { ExecutionContext } from 'ava'; -import { Client } from '@temporalio/client'; +import { Client, WorkflowFailedError } from '@temporalio/client'; import { workflowInterceptorModules } from '@temporalio/testing'; import { bundleWorkflowCode } from '@temporalio/worker'; import type { TestWorkflowEnvironment } from './helpers'; @@ -12,7 +12,10 @@ import { makeConfigurableEnvironmentTestFn, } from './helpers-integration'; import { + parentWorkflowChildDefinition, + parentWorkflowChildDefinitionInvalidCallSiteHints, Order, + parentWorkflowChildString, Receipt, workflowTypeHints, workflowWithTypeHints, @@ -149,3 +152,54 @@ test('same-type continue-as-new reuses definition-supplied input and output type assertReceipt(t, result); }); }); + +test('child workflow uses definition-supplied input and output type hints', async (t) => { + const h = configurableHelpers(t, t.context.workflowBundle, t.context.env); + const client = makeClient(t.context.env); + const worker = await h.createWorker({ dataConverter }); + + await worker.runUntil(async () => { + const result = await client.workflow.execute(parentWorkflowChildDefinition, { + workflowId: `wf-${randomUUID()}`, + taskQueue: h.taskQueue, + args: [new Order('order-1', 12345n)], + }); + + assertReceipt(t, result); + }); +}); + +test('child workflow uses call-site input and output type hints for string workflow type', async (t) => { + const h = configurableHelpers(t, t.context.workflowBundle, t.context.env); + const client = makeClient(t.context.env); + const worker = await h.createWorker({ dataConverter }); + + await worker.runUntil(async () => { + const result = await client.workflow.execute(parentWorkflowChildString, { + workflowId: `wf-${randomUUID()}`, + taskQueue: h.taskQueue, + args: [new Order('order-1', 12345n)], + }); + + assertReceipt(t, result); + }); +}); + +test('child workflow definition with call-site type hints is invalid', async (t) => { + const h = configurableHelpers(t, t.context.workflowBundle, t.context.env); + const client = makeClient(t.context.env); + const worker = await h.createWorker({ dataConverter }); + + await worker.runUntil(async () => { + const err = await t.throwsAsync( + client.workflow.execute(parentWorkflowChildDefinitionInvalidCallSiteHints, { + workflowId: `wf-${randomUUID()}`, + taskQueue: h.taskQueue, + args: [new Order('order-1', 12345n)], + }), + { instanceOf: WorkflowFailedError } + ); + + t.regex(err?.cause?.message ?? '', /Workflow type hints cannot be supplied at the call site/); + }); +}); diff --git a/packages/test/src/workflows/type-hints.ts b/packages/test/src/workflows/type-hints.ts index 03b4aada9c..8200fc2059 100644 --- a/packages/test/src/workflows/type-hints.ts +++ b/packages/test/src/workflows/type-hints.ts @@ -1,5 +1,5 @@ import type { PayloadTypeHints } from '@temporalio/common'; -import { continueAsNew, defineWorkflowOptions } from '@temporalio/workflow'; +import { continueAsNew, defineWorkflowOptions, executeChild } from '@temporalio/workflow'; export class Order { constructor( @@ -53,9 +53,13 @@ function assertOrder(order: Order): void { } } -function makeReceipt(order: Order): Receipt { - assertOrder(order); - return new Receipt(order.id, order.totalCents); +function assertReceipt(receipt: Receipt): void { + if (!(receipt instanceof Receipt)) { + throw new Error('Expected Receipt result'); + } + if (typeof receipt.totalCents !== 'bigint') { + throw new Error('Expected Receipt.totalCents to be a bigint'); + } } defineWorkflowOptions(workflowWithTypeHints, { @@ -66,5 +70,39 @@ export async function workflowWithTypeHints(order: Order): Promise { if (order.remainingRuns > 0) { await continueAsNew(new Order(order.id, order.totalCents, order.remainingRuns - 1)); } - return makeReceipt(order); + return new Receipt(order.id, order.totalCents); } + +export async function parentWorkflowChildDefinition(order: Order): Promise { + assertOrder(order); + const receipt = await executeChild(workflowWithTypeHints, { args: [order] }); + assertReceipt(receipt); + return receipt; +} +defineWorkflowOptions(parentWorkflowChildDefinition, { + staticOptions: { typeHints: workflowTypeHints }, +}); + +export async function parentWorkflowChildString(order: Order): Promise { + assertOrder(order); + const receipt = await executeChild('workflowWithTypeHints', { + args: [order], + typeHints: workflowTypeHints, + }); + assertReceipt(receipt); + return receipt; +} +defineWorkflowOptions(parentWorkflowChildString, { + staticOptions: { typeHints: workflowTypeHints }, +}); + +export async function parentWorkflowChildDefinitionInvalidCallSiteHints(order: Order): Promise { + await executeChild(workflowWithTypeHints, { + args: [order], + typeHints: workflowTypeHints, + }); +} +defineWorkflowOptions(parentWorkflowChildDefinitionInvalidCallSiteHints, { + workflowDefinitionOptions: { failureExceptionTypes: [TypeError] }, + staticOptions: { typeHints: workflowTypeHints }, +}); diff --git a/packages/workflow/src/internals.ts b/packages/workflow/src/internals.ts index 010fdefac3..8392e5e959 100644 --- a/packages/workflow/src/internals.ts +++ b/packages/workflow/src/internals.ts @@ -16,6 +16,7 @@ import type { WorkflowDefinitionOptions, WorkflowSerializationContext, PayloadTypeHints, + TypeHint, } from '@temporalio/common'; import { defaultFailureConverter, @@ -115,6 +116,7 @@ export interface Completion { resolve(val: Success): void; reject(reason: Error): void; context?: Context; + typeHint?: TypeHint; } export interface Condition { @@ -749,10 +751,10 @@ export class Activator implements ActivationHandler { if (!activation.result) { throw new TypeError('Got ResolveChildWorkflowExecution activation with no result'); } - const { resolve, reject, context } = this.consumeCompletion('childWorkflowComplete', getSeq(activation)); + const { resolve, reject, context, typeHint } = this.consumeCompletion('childWorkflowComplete', getSeq(activation)); if (activation.result.completed) { const completed = activation.result.completed; - const result = completed.result ? this.payloadConverter.fromPayload(completed.result, context) : undefined; + const result = completed.result ? this.payloadConverter.fromPayload(completed.result, context, typeHint) : undefined; resolve(result); } else if (activation.result.failed) { const { failure } = activation.result.failed; diff --git a/packages/workflow/src/workflow.ts b/packages/workflow/src/workflow.ts index 166fe3202c..e2f8bf58f6 100644 --- a/packages/workflow/src/workflow.ts +++ b/packages/workflow/src/workflow.ts @@ -479,6 +479,7 @@ function startChildWorkflowExecutionNextHandler({ resolve, reject, context, + typeHint: options.typeHints?.outputType, }); }); untrackPromise(startPromise);