Nexus Client Code Generator
A Nexus Service is a contract meant to be shared across team boundaries. Those teams often work in different languages, so the same request and response types get hand-written once per SDK. Hand-written copies drift: a field is required on one side and optional on the other, a bound is enforced by the caller but not the handler.
The Nexus Client Code Generator removes those copies.
You describe your types and Nexus Operations once in a definition file, and the generator emits the equivalent library code for Go, Java, Python, and TypeScript.
The generator is a command-line tool named nexgen, distributed from the temporalio/nex-gen repository.
nexgen is pre-release software, currently at version 0.2.1.
The supported schema subset, command-line options, and emitted code may change incompatibly before a stable release.
It is not yet published to any package registry, so you build it from source as described in Install the generator.
What the generator produces
For every type in your definition file, the generator emits three things per language.
- A typed model. An idiomatic struct, class, interface, or dataclass, with doc comments carried over from the schema.
- A shared runtime validator. One validator per type, used when a value is parsed off the wire and again when it is serialized onto the wire, so a payload cannot enter or leave your service in a shape the contract forbids.
- A Nexus Service Contract definition. The generated Service and Operation declarations you register on a Worker and call from a caller Workflow.
Constraint failures do not surface one at a time.
They aggregate into a single native error listing every violation, each naming the offending field and the bound it broke.
A handler maps that error to a BAD_REQUEST Nexus error, so a malformed request tells the caller everything that was wrong with it in one response.
The supported schema subset is deliberately strict. Anything ambiguous, or anything that cannot be expressed identically in all four languages, is rejected when you run the generator, with a diagnostic explaining how to express it instead. The generator prefers to fail loudly at generation time over emitting code that behaves differently in one language than another.
Supported languages
nexgen generates Go, Java, Python, and TypeScript.
Definition files
Types are modeled with JSON Schema 2020-12. A definition file comes in two flavors.
Pure JSON Schema. The root of the document is itself a type, and reusable types live under $defs.
Use this when you only need data models shared across languages, with no Service or Operation declarations.
Nexus document. Add a root nexusrpc: "1.0.0" marker to enable a services section.
The root becomes an envelope: Services and their Operations sit at the top level, and your types live under $defs.
The examples on this page use samples/schemas/chat.nexusrpc.yaml from the repository, abbreviated here:
nexusrpc: '1.0.0'
$schema: https://json-schema.org/draft/2020-12/schema
services:
ChatService:
fqn: example.chat.v1.ChatService
description: Send messages and look up rooms.
operations:
sendMessage:
description: Post a message to a room.
input: { $ref: '#/$defs/SendMessageInput' }
output: { $ref: '#/$defs/SendMessageOutput' }
getRoom:
description: Look up a room by id.
input:
type: object
additionalProperties: false
properties:
roomId: { type: string }
required: [roomId]
output: { $ref: '#/$defs/Room' }
ping:
description: Liveness probe.
$defs:
SendMessageInput:
type: object
additionalProperties: false
properties:
roomId: { type: string }
message: { $ref: '#/$defs/Message' }
required: [roomId, message]
SendMessageOutput:
type: object
additionalProperties: false
properties:
messageId: { type: string }
required: [messageId]
fqn is the wire name of the Service, the name callers reference when executing an Operation.
An Operation's input and output are each optional.
The ping Operation above declares neither, which generates an Operation that takes and returns nothing.
When present, each must be an object type, so that a field can be added later without breaking the wire format.
The repository holds four example definitions under samples/schemas/:
chat.nexusrpc.yaml,
the feature-diverse showcase.nexusrpc.yaml,
the pure-schema temporal.yaml,
and a multi-file closure under kb/ showing how types split across files resolve through $ref.
The kb/ closure starts at kb.nexusrpc.yaml and pulls in types from its content/ and tree/ subdirectories.
Install the generator
Build the nexgen binary from source with a Rust toolchain:
git clone https://github.com/temporalio/nex-gen.git
cd nex-gen
cargo build --release
The binary lands at target/release/nexgen.
Confirm it works and check which targets your build supports:
./target/release/nexgen --version
./target/release/nexgen --help
Generate code
Every language uses the same shape: nexgen <language> <input>... --output <dir>.
Inputs are positional and may be files or directories, so you can pass a whole multi-file schema closure. Some languages have extra flags.
The output directory name becomes the generated package or module name.
Name it after your domain, such as chat, not after the language.
Pointing --output at a directory named go produces package go, which is not valid Go.
Go
nexgen go samples/schemas/chat.nexusrpc.yaml --output ./chat
Place the output directory inside your Go module.
The package name is the directory name, so the example above generates package chat in ./chat/chat.go alongside ./chat/definitions.go.
Java
Java requires --package-name, and its last dot-separated segment must match the --output directory name:
nexgen java samples/schemas/chat.nexusrpc.yaml \
--output ./src/main/java/com/example/chat \
--package-name com.example.chat
If the two disagree, generation stops and tells you how to reconcile them:
`--package-name com.example.wrong` must end with the output directory name `chat`,
but its last segment is `wrong`; point `--output` at a directory named `wrong` or
change the package's last segment to `chat`
Python
nexgen python samples/schemas/chat.nexusrpc.yaml --output ./chat
This writes an importable package: models.py, services.py, and an __init__.py that re-exports both.
Generated models are Pydantic models, so your Worker and Client must use the Pydantic Data Converter described in Use Pydantic models.
TypeScript
nexgen ts samples/schemas/chat.nexusrpc.yaml --output ./chat
TypeScript accepts --date-time-types to choose how temporal format fields are represented in memory:
nexgen ts samples/schemas/temporal.yaml --output ./chat --date-time-types temporal
string(the default) keeps every temporal field as the RFC 3339 string that appears on the wire. It has no runtime dependency and round-trips losslessly, but you parse and compare the strings yourself.datemapsdate-timefields to a JavaScriptDate. This is lossy: aDateis a UTC instant, so the original offset is folded away and precision is capped at milliseconds.temporalmaps to the TC39 Temporal API, preserving offset and sub-second precision, and requires theTemporalglobal or a polyfill.
Use the generated code
The generated Service definition is a normal Nexus Service definition. You register it on a Worker and call it from a caller Workflow exactly as described in your SDK's Nexus guide. What differs per language is how the validator gets invoked.
| SDK | How validation reaches the wire | Extra step |
|---|---|---|
| Go | Generated MarshalJSON and UnmarshalJSON on each model | None |
| Java | Generated Jackson serializer and deserializer on each model | None |
| Python | Pydantic model validation | Use the Pydantic data converter |
| TypeScript | Generated mapper classes | Call the mapper yourself |
In Go, Java, and Python the validator sits in the serialization hook the Temporal data converter already calls, so validation happens on its own once the models are in use. TypeScript requires an explicit call, covered in Validate payloads in TypeScript.
Go
The generated ChatService value carries the Service name and one typed Operation reference per Operation.
Register handlers on a Worker:
service := nexus.NewService(chat.ChatService.ServiceName)
sendMessage := nexus.NewSyncOperation(chat.ChatService.SendMessage.Name(),
func(ctx context.Context, input chat.SendMessageInput, _ nexus.StartOperationOptions) (chat.SendMessageOutput, error) {
return chat.SendMessageOutput{MessageId: store(input)}, nil
})
if err := service.Register(sendMessage); err != nil {
return err
}
w.RegisterNexusService(service)
Call it from a caller Workflow, passing the generated Operation reference so the SDK type-checks the request and response:
client := workflow.NewNexusClient("chat-endpoint", chat.ChatService.ServiceName)
var output chat.SendMessageOutput
err := client.ExecuteOperation(
ctx,
chat.ChatService.SendMessage,
chat.SendMessageInput{RoomId: "r1", Message: chat.Message{Kind: "text", Body: "hi"}},
workflow.NexusOperationOptions{},
).Get(ctx, &output)
Java
The generator emits ChatService as an interface annotated with @Service, with one @Operation method per Operation.
On the handler side, write a separate implementation class that points at the generated interface with @ServiceImpl, and return an OperationHandler from each @OperationImpl method:
@ServiceImpl(service = ChatService.class)
public final class ChatServiceImpl {
@OperationImpl
public OperationHandler<SendMessageInput, SendMessageOutput> sendMessage() {
return OperationHandler.sync((ctx, details, input) -> new SendMessageOutput(store(input)));
}
}
Register it on a Worker with worker.registerNexusServiceImplementation(new ChatServiceImpl()).
On the caller side, the same interface works directly as a Workflow stub:
ChatService chat = Workflow.newNexusServiceStub(
ChatService.class,
NexusServiceOptions.newBuilder()
.setEndpoint("chat-endpoint")
.setOperationOptions(NexusOperationOptions.newBuilder()
.setScheduleToCloseTimeout(Duration.ofSeconds(10))
.build())
.build());
SendMessageOutput output = chat.sendMessage(new SendMessageInput("r1", message));
Python
The generator emits ChatService as a @service-decorated class whose attributes are typed Operation declarations.
Bind a handler to it:
@service_handler(service=ChatService)
class ChatServiceHandler:
@sync_operation
async def send_message(
self, ctx: StartOperationContext, input: SendMessageInput
) -> SendMessageOutput:
return SendMessageOutput(messageId=store(input))
Pass the handler to your Worker as nexus_service_handlers=[ChatServiceHandler()], then call it from a caller Workflow:
client = workflow.create_nexus_client(service=ChatService, endpoint="chat-endpoint")
output = await client.execute_operation(
ChatService.send_message,
SendMessageInput(roomId="r1", message=Message(kind="text", body="hi")),
)
Generated Python fields are snake_case with the wire name as an alias.
Construct models with either name, and read them with the snake_case attribute: SendMessageInput(roomId="r1", ...) constructs, and output.message_id reads.
TypeScript
The generator emits a chatService Service definition plus, for each type, an interface and a companion <Type>Mapper class:
export const chatService = nexus.service('example.chat.v1.ChatService', {
sendMessage: nexus.operation<SendMessageInput, SendMessageOutput>({ name: 'SendMessage' }),
getRoom: nexus.operation<GetRoomInput, Room>({ name: 'GetRoom' }),
ping: nexus.operation<void, void>({ name: 'Ping' }),
});
Register a handler against that definition with nexus.serviceHandler(chatService, { ... }), and create a caller with workflow.createNexusServiceClient({ service: chatService, endpoint: 'chat-endpoint' }).
Validate payloads in TypeScript
In TypeScript the generated validator only runs when you call the mapper. No generated payload converter exists, so nothing calls it for you.
Each generated type comes with a mapper exposing two methods.
fromIntermediate validates an untrusted plain value and returns the typed model.
toIntermediate validates a model and returns its plain wire form.
Call them at both edges of every Operation, on the handler side and the caller side:
const handler = nexus.serviceHandler(chatService, {
async sendMessage(_ctx, input) {
const request = new SendMessageInputMapper().fromIntermediate(input);
const output = { messageId: await store(request) };
return new SendMessageOutputMapper().toIntermediate(output) as SendMessageOutput;
},
});
The cast on the return value is expected: toIntermediate returns unknown, because its result is a plain wire value rather than the model type the Operation declares.
Skipping the mapper is the failure to watch for, because nothing reports it.
The value handed to your handler is typed as the model, since nexus.operation<SendMessageInput, SendMessageOutput> declares it that way, but at runtime it is only whatever was deserialized.
A handler that ignores the mapper compiles, type-checks, and returns correct results for valid payloads, while enforcing none of the constraints in your schema.
When a payload does violate the contract, fromIntermediate throws a ValidationError carrying every violation at once:
ValidationError: 2 validation error(s): roomId: required; message.body: expected string
The error also exposes a violations array of { path, reason } objects, so a handler can convert it into a BAD_REQUEST Nexus error with the full list intact.
Schema defaults
A default in your schema is applied when reading, and is never written back to the wire.
The field stays optional in the generated model, and each language exposes the default differently.
- Go and Java generate an accessor:
PriorityOrDefault()andgetPriorityOrDefault(). - Python applies the default through Pydantic, so reading the attribute returns it.
- TypeScript exports a module-level constant, such as
DEFAULT_PRIORITY, that you apply yourself withvalue.priority ?? DEFAULT_PRIORITY.
Supported schema features
The generator implements a curated subset of JSON Schema 2020-12 chosen so that every accepted construct lowers identically into all four languages.
Fully supported: properties, required, default, minProperties and maxProperties, dependentRequired, string and numeric bounds, items, minItems and maxItems, minContains and maxContains, allOf, the recognized nullable pattern oneOf: [{type: T}, {type: "null"}], and the title, description, and deprecated annotations.
Partially supported: type (single-string form only), additionalProperties, propertyNames, const and enum (scalars only), format, pattern (a portable RE2-safe subset), multipleOf, contentEncoding, uniqueItems, contains, oneOf (branches must be separable by a decidable selector), and $ref with $defs (local files only).
Deliberately rejected, because they have no coherent typed lowering across all four languages: anyOf, not, if/then/else, dependentSchemas, prefixItems, unevaluatedProperties, unevaluatedItems, contentMediaType, and contentSchema.
For the current per-keyword support table, see the nex-gen README.
- temporalio/nex-gen for the generator, its README, and the example schemas.
- Nexus Services for the Service contract concept.
- Nexus feature guides for registering Services and calling Operations: Go | Java | Python | TypeScript