Skip to content

docs: design spec for binding a second contract to a TypedClient - #353

Closed
btravers wants to merge 3 commits into
mainfrom
spec/typed-client-contract-binding
Closed

docs: design spec for binding a second contract to a TypedClient#353
btravers wants to merge 3 commits into
mainfrom
spec/typed-client-contract-binding

Conversation

@btravers

Copy link
Copy Markdown
Collaborator

Summary

Design doc only — no source changes. Records the investigation and decision behind millenium!56319 note 2210871, which asked whether the contract belongs on TypedClient.create and whether a NestJS provider should exist.

Decision: add a synchronous, memoized TypedClient#for(contract).

const client = (await TypedClient.create({ contract: leaseContract, client: temporal })).get();

await client.startWorkflow("computeOne…", { workflowId, args });          // unchanged
await client.for(platoContract).startWorkflow("…", { workflowId, args }); // second contract, no reconnect

Existing call sites are untouched, nothing is renamed, no new type is exported.

Why this shape

create is async and fallible only because of ensureConnected(). Contract binding is sync and free — contract feeds just taskQueue, workflow lookup, schema validation and TypedScheduleClient. Those two concerns being fused is what pushes construction into a request handler in the originating MR. for() separates them: it reuses a Client that already passed the schedule check at create() time, so it cannot fail and needs no AsyncResult wrapper.

Ruled out (reasons recorded in the spec)

  • A NestJS integration package — reverses PR chore: remove NestJS integration #116, which removed client-nestjs/worker-nestjs deliberately. The consumer also has its own in-house @emeria/nestjs-temporal, and the removed provider was one-module-per-contract, so it would not have addressed the complaint.
  • Contract at the call site — taxes every call site for a case that does not exist yet (one contract per app today), and client.schedule would need the contract threaded through TypedScheduleClient too.

Honest caveat

At the current one-contract-per-app, for() buys a sync binding step, not fewer clients. The payoff arrives when one process binds two contracts. Recommended anyway because 8.0-beta is the cheap window for it and the surface is one method — but "do nothing yet" stays defensible, and the spec says so.

Also noted

Two findings unrelated to the decision, both recorded:

  • The originating snippet's mapErr + tag("@temporal-contract/TechnicalError") will not compile on 8.0 — create returns AsyncResult<…, never>, so it collapses to .get().
  • startChildWorkflow / executeChildWorkflow TSDoc documents a same-contract overload that does not exist; contract is required in both signatures. To be fixed separately.

Why .agents/specs/ and not docs/

The VitePress config sets no srcExclude, so every .md under docs/ becomes a published page. An internal design doc does not belong on the public site.

🤖 Generated with Claude Code

Records the investigation and decision from millenium!56319 note 2210871:
add a synchronous, memoized TypedClient#for(contract) rather than moving the
contract to the call site or re-introducing a NestJS integration package.

Spec lives in .agents/specs/ rather than docs/ because the VitePress config
sets no srcExclude, so any .md under docs/ becomes a published page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 29, 2026 18:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an internal design spec documenting the investigation and the chosen API shape for binding an additional contract to an already-connected TypedClient (a synchronous, memoized TypedClient#for(contract)), while explicitly recording rejected alternatives (NestJS integration, contract-at-callsite).

Changes:

  • Introduces a design document describing the rationale, semantics, implementation sketch, and test plan for TypedClient#for(contract).
  • Captures related findings discovered during investigation (notably a startChildWorkflow/executeChildWorkflow TSDoc mismatch) for separate follow-up.

Comment on lines +110 to +111
The map erases the type parameter, so storing and reading each cross a cast —
contained to the two lines inside `for()`.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3b414ba. Now reads "the store and the read each require a cast, contained to the two lines inside for()."

Comment on lines +91 to +94
const client = (await TypedClient.create({ contract: leaseContract, client: temporal })).get();

await client.startWorkflow("computeOneLessorTaxDeclarationWorkflow", { workflowId, args });
await client.for(platoContract).startWorkflow("someOtherWorkflow", { workflowId, args });

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it s not a second contract, the client should not depend on a contract

Suggested change
const client = (await TypedClient.create({ contract: leaseContract, client: temporal })).get();
await client.startWorkflow("computeOneLessorTaxDeclarationWorkflow", { workflowId, args });
await client.for(platoContract).startWorkflow("someOtherWorkflow", { workflowId, args });
const client = await TypedClient.create({ client: temporal }).get();
await client.for(leaseContract).startWorkflow("computeOneLessorTaxDeclarationWorkflow", { workflowId, args });
await client.for(platoContract).startWorkflow("someOtherWorkflow", { workflowId, args });

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 3b414ba — the client no longer knows about contracts at all.

TypedClient is now connection-scoped with no type parameter; the contract-bound surface moved to ContractClient<TContract>, reached only via for(). No contract is privileged, which was the wart the previous draft accepted and you correctly rejected.

Two consequences worth confirming, both now in the spec:

  • The @temporalio/client >= 1.16 check (missing client.schedule) moves from the old constructor into create. It is a property of the connection, not of any contract — and moving it is what keeps for() infallible, so it returns ContractClient<TContract> directly with no AsyncResult wrapper.
  • create({ contract, client }) is dropped outright rather than deprecated, since retaining it would preserve the coupling being removed. createOrThrow goes with it (already @deprecated, takes a contract positionally). That makes this a breaking change — a major changeset, folded into the beta line since 8.0 has not shipped. About 6 TypedClient<typeof X> annotations in tests/examples become ContractClient<typeof X>, and upgrade-to-v8.md gains a migration section.

Also adopted your await TypedClient.create({...}).get() spelling throughout — the fluent form is what docs/index.md already uses, and my draft had the clunkier (await create(...)).get().

Benoit Travers and others added 2 commits July 29, 2026 21:12
Review feedback: the client should not depend on a contract at all, so the
privileged bootstrap contract in the previous draft is gone.

TypedClient becomes connection-scoped with no type parameter; the contract-bound
surface moves to ContractClient<TContract>, reached via TypedClient#for(contract).
The >= 1.16 schedule check moves to create, where it is a property of the
connection rather than of any contract, which keeps for() infallible.

create({ contract, client }) is dropped rather than deprecated — retaining it
would preserve the coupling being removed — and createOrThrow goes with it.
Records the resulting breaking changes, the ~6 type annotations to retype, and
the migration section owed to upgrade-to-v8.md.

Also adopts the repo's fluent `await create({...}).get()` idiom and fixes an
ungrammatical sentence flagged in review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nt spec

Adds a Non-goals entry explaining the client/worker asymmetry: the client's
contract coupling was accidental (connection vs schema) while the worker's is
essential (a Worker instance polls exactly one task queue and the contract is
its job description), with the NativeConnection-sharing story already covered
by createWorker's option spread.

Also records the deliberate choice to keep the TypedClient name, the
memoization trade-off and its constraint on a future for(contract, options)
overload, adds a type-level migration assertion (TypedClient takes no type
argument), and reflows one inline code span that was broken across lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@btravers

Copy link
Copy Markdown
Collaborator Author

Superseded — the spec (with the review revisions) merged to main as part of #354.

@btravers btravers closed this Jul 30, 2026
@btravers
btravers deleted the spec/typed-client-contract-binding branch July 30, 2026 01:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants