Skip to content

Bug: restate-sdk-clients loses generic type for workflows #662

@Umbrien

Description

@Umbrien

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 signatures

Note

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" }),
});

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions