-
Notifications
You must be signed in to change notification settings - Fork 17
Description
Bug: workflowClient() loses generic type — IngressWorkflowClient<unknown> and workflowSubmit is never
Package
@restatedev/restate-sdk-clients@1.11.1 (also affects @restatedev/restate-sdk-core@1.11.1)
Description
When using the ingress client (connect() from @restatedev/restate-sdk-clients) to create a workflow client, the generic type parameter D in workflowClient<D>() is never correctly inferred, regardless of how it's called. The result is always IngressWorkflowClient<unknown>, making workflowSubmit, workflowAttach, and workflowOutput resolve to never.
Root Cause
WorkflowDefinition<P, M> in @restatedev/restate-sdk-core is defined as:
// @restatedev/restate-sdk-core/dist/core.d.ts
export type WorkflowDefinition<P extends string, M> = {
name: P;
};The handler map M is a phantom type parameter — it exists in the type signature but has no structural representation (the object is just { name: P }).
The workflow() function returns WorkflowDefinition<P, M> and TypeScript preserves the phantom M in the declared type of the variable:
// restate.workflow() returns WorkflowDefinition<"MyWorkflow", { run: ..., approve: ..., ... }>
const myWorkflow = restate.workflow({ name: "MyWorkflow", handlers: { ... } });
// typeof myWorkflow = WorkflowDefinition<"MyWorkflow", HandlerMap>However, when this value is passed to the ingress client's workflowClient<D>():
// @restatedev/restate-sdk-clients api.d.ts
interface Ingress {
workflowClient<D>(opts: WorkflowDefinitionFrom<D>, key: string): IngressWorkflowClient<Workflow<D>>;
}TypeScript must infer D from WorkflowDefinitionFrom<D>, which is:
type WorkflowDefinitionFrom<M> = M extends WorkflowDefinition<string, unknown> ? M : WorkflowDefinition<string, M>;The inference fails because WorkflowDefinition<P, M> is structurally just { name: P }. TypeScript can only infer D = { name: "MyWorkflow" } from the structure, losing the phantom M. Then Workflow<D> resolves to unknown, and all the conditional types in IngressWorkflowClient that check M extends Record<string, unknown> and M["run"] extends (...) collapse to never.
Reproduction
import * as restate from "@restatedev/restate-sdk";
import { connect } from "@restatedev/restate-sdk-clients";
type TopUpInput = { userId: number; txHash: string };
const myWorkflow = restate.workflow({
name: "MyWorkflow",
handlers: {
run: async (ctx: restate.WorkflowContext, input: TopUpInput) => {
return { status: "done" as const };
},
},
});
const client = connect({ url: "http://localhost:8080" });
// ❌ All of these produce IngressWorkflowClient<unknown>:
// Attempt 1: Pass object directly (inference fails)
const wf1 = client.workflowClient(myWorkflow, "key1");
// ^? IngressWorkflowClient<unknown>
// Attempt 2: Explicit generic with typeof (same result)
const wf2 = client.workflowClient<typeof myWorkflow>(myWorkflow, "key1");
// ^? IngressWorkflowClient<unknown>
// Attempt 3: Export type alias (same result)
type MyWorkflow = typeof myWorkflow;
const wf3 = client.workflowClient<MyWorkflow>(myWorkflow, "key1");
// ^? IngressWorkflowClient<unknown>
// All of these fail:
await wf1.workflowSubmit({ userId: 1, txHash: "abc" });
// ^^^^^^^^^^^^^^ — Type 'never' has no call signaturesNote
The internal SDK client (ctx.workflowClient<typeof myWorkflow>(...) inside a Restate handler) uses the same WorkflowDefinitionFrom<D> type but is documented to work with the typeof pattern. The issue may be specific to how the ingress client's .d.cts entry point resolves cross-package types, or may be a general phantom type inference bug in the SDK's type definitions.
Environment
- @restatedev/restate-sdk: 1.11.1
- @restatedev/restate-sdk-clients: 1.11.1
- @restatedev/restate-sdk-core: 1.11.1
- TypeScript: via
bun-types(Bun runtime) - tsconfig.json:
"moduleResolution": "node","module": "ES2022","strict": true
Additional Context
The published package.json for both restate-sdk and restate-sdk-clients sets:
{
"types": "./dist/index.d.cts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}With "moduleResolution": "node", TypeScript follows the "types" field → .d.cts files, while runtime imports follow ESM. This CJS/ESM type mismatch may contribute to the issue if TypeScript resolves @restatedev/restate-sdk-core's WorkflowDefinition through different module paths for the SDK vs the clients package.
Suggested Fix
The phantom type WorkflowDefinition<P, M> = { name: P } cannot carry M structurally. Two potential fixes:
Option A: Add a branded phantom field
declare const __handlers: unique symbol;
export type WorkflowDefinition<P extends string, M> = {
name: P;
[__handlers]?: M; // phantom brand — never set at runtime
};This preserves M structurally so TypeScript can infer it from the argument.
Option B: Change workflowClient to accept the definition object directly
Instead of using WorkflowDefinitionFrom<D> (conditional type inference), accept WorkflowDefinition<string, D> directly:
workflowClient<D>(opts: WorkflowDefinition<string, D>, key: string): IngressWorkflowClient<D>;This avoids the lossy conditional type and lets TypeScript infer D directly as the handler map.
Workaround
Use raw HTTP fetch() calls to the Restate ingress instead of the typed SDK client:
await fetch(`${RESTATE_URL}/MyWorkflow/${key}/run/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: 1, txHash: "abc" }),
});