What are you really trying to do?
We use a custom PayloadConverter that registers instanceof-based type transformers (e.g. Decimal via superjson). Our project runs under ESM (--import=tsx). We need instanceof checks in the payload converter to work against values created by activity code in the same process.
Describe the bug
Worker.create() unconditionally calls loadDataConverter() on the dataConverter option, which uses require() to load the module at payloadConverterPath. This creates a separate CJS module cache from the ESM-imported application code. As a result, instanceof checks in custom payload converters silently fail -- classes loaded via require() are different objects from the same classes loaded via import.
The Client already handles this correctly by checking isLoadedDataConverter() before calling loadDataConverter():
// @temporalio/client/src/base-client.ts
const dataConverter = options?.dataConverter ?? {};
this.loadedDataConverter = isLoadedDataConverter(dataConverter)
? dataConverter
: loadDataConverter(dataConverter);
The Worker does not have this check:
// @temporalio/worker/src/worker-options.ts:1059
loadedDataConverter: loadDataConverter(opts.dataConverter),
The failure is completely silent -- instanceof returns false, the custom serializer is skipped, and values fall through to plain JSON serialization, losing their type information. There is no error or warning.
Minimal Reproduction
See the three attached files for a self-contained reproduction using @temporalio/testing. Dependencies: @temporalio/worker, @temporalio/testing, decimal.js.
bug-repro-payload-converter.ts -- a minimal custom PayloadConverter that detects Decimal values via instanceof and wraps them with format metadata for round-tripping.
bug-repro-workflows.ts -- a minimal workflow that calls an activity returning a Decimal, then inspects the result type.
bug-repro.test.ts -- two test cases:
- "BUG: without workaround" -- demonstrates that
Decimal silently degrades to a plain string (typeof result === 'string', instanceof Decimal === false, Decimal methods lost).
- "WORKAROUND: replacing CJS converter" -- demonstrates that mutating
worker.options.loadedDataConverter.payloadConverter after Worker.create() fixes the issue.
Run with: npx tsx --test bug-repro.test.ts
What happens: The activity creates a Decimal using the ESM module (decimal.mjs). The Worker serializes it using the payload converter loaded via require(), where instanceof Decimal checks against the CJS class (decimal.js). These are different class objects:
import { Decimal as EsmDecimal } from 'decimal.js'; // decimal.mjs
const { Decimal: CjsDecimal } = require('decimal.js'); // decimal.js
EsmDecimal === CjsDecimal; // false
new EsmDecimal('1') instanceof CjsDecimal; // false
So instanceof returns false, the custom serializer is skipped, and the Decimal is serialized as a plain JSON string. On the receiving side, calling .isZero() on the deserialized value throws isZero is not a function.
Workaround: Mutate the loaded converter after Worker.create():
import { payloadConverter } from './payload-converter';
const worker = await Worker.create({ /* ... */ });
worker.options.loadedDataConverter.payloadConverter = payloadConverter;
Environment/Versions
- OS and processor: M1 Mac (also reproducible on x86 Linux)
- SDK version:
@temporalio/worker 1.15.0
- Using Temporal Cloud (but also reproducible with the local test server from
@temporalio/testing)
Additional context
Repro Test Files
Suggested fix -- apply the same isLoadedDataConverter guard that the Client already uses:
--- a/packages/worker/src/worker-options.ts
+++ b/packages/worker/src/worker-options.ts
@@ -1,4 +1,4 @@
-import { loadDataConverter } from '@temporalio/common/lib/internal-non-workflow';
+import { isLoadedDataConverter, loadDataConverter } from '@temporalio/common/lib/internal-non-workflow';
@@ -1056,7 +1056,9 @@
defaultHeartbeatThrottleIntervalMs: msToNumber(opts.defaultHeartbeatThrottleInterval),
- loadedDataConverter: loadDataConverter(opts.dataConverter),
+ loadedDataConverter: isLoadedDataConverter(opts.dataConverter)
+ ? opts.dataConverter
+ : loadDataConverter(opts.dataConverter),
activities,
This lets users pass a pre-loaded LoadedDataConverter directly, bypassing require() and keeping the same module cache as the activity code:
import { payloadConverter } from './payload-converter';
import { defaultFailureConverter } from '@temporalio/common';
const worker = await Worker.create({
// ...
dataConverter: {
payloadConverter, // ESM-imported instance
failureConverter: defaultFailureConverter,
payloadCodecs: [],
},
});
This is especially impactful for packages with separate CJS/ESM entry points (like decimal.js, neverthrow, etc.) where Node.js loads them into separate module caches, causing all instanceof-based type detection to silently break.
What are you really trying to do?
We use a custom
PayloadConverterthat registersinstanceof-based type transformers (e.g.Decimalvia superjson). Our project runs under ESM (--import=tsx). We needinstanceofchecks in the payload converter to work against values created by activity code in the same process.Describe the bug
Worker.create()unconditionally callsloadDataConverter()on thedataConverteroption, which usesrequire()to load the module atpayloadConverterPath. This creates a separate CJS module cache from the ESM-imported application code. As a result,instanceofchecks in custom payload converters silently fail -- classes loaded viarequire()are different objects from the same classes loaded viaimport.The
Clientalready handles this correctly by checkingisLoadedDataConverter()before callingloadDataConverter():The Worker does not have this check:
The failure is completely silent --
instanceofreturnsfalse, the custom serializer is skipped, and values fall through to plain JSON serialization, losing their type information. There is no error or warning.Minimal Reproduction
See the three attached files for a self-contained reproduction using
@temporalio/testing. Dependencies:@temporalio/worker,@temporalio/testing,decimal.js.bug-repro-payload-converter.ts-- a minimal customPayloadConverterthat detectsDecimalvalues viainstanceofand wraps them with format metadata for round-tripping.bug-repro-workflows.ts-- a minimal workflow that calls an activity returning aDecimal, then inspects the result type.bug-repro.test.ts-- two test cases:Decimalsilently degrades to a plain string (typeof result === 'string',instanceof Decimal === false, Decimal methods lost).worker.options.loadedDataConverter.payloadConverterafterWorker.create()fixes the issue.Run with:
npx tsx --test bug-repro.test.tsWhat happens: The activity creates a
Decimalusing the ESM module (decimal.mjs). The Worker serializes it using the payload converter loaded viarequire(), whereinstanceof Decimalchecks against the CJS class (decimal.js). These are different class objects:So
instanceofreturnsfalse, the custom serializer is skipped, and theDecimalis serialized as a plain JSON string. On the receiving side, calling.isZero()on the deserialized value throwsisZero is not a function.Workaround: Mutate the loaded converter after
Worker.create():Environment/Versions
@temporalio/worker1.15.0@temporalio/testing)Additional context
Repro Test Files
Suggested fix -- apply the same
isLoadedDataConverterguard that the Client already uses:This lets users pass a pre-loaded
LoadedDataConverterdirectly, bypassingrequire()and keeping the same module cache as the activity code:This is especially impactful for packages with separate CJS/ESM entry points (like
decimal.js,neverthrow, etc.) where Node.js loads them into separate module caches, causing allinstanceof-based type detection to silently break.