Skip to main content

Nexus Client Code Generator

View Markdown

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.

caution

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.

info

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.
  • date maps date-time fields to a JavaScript Date. This is lossy: a Date is a UTC instant, so the original offset is folded away and precision is capped at milliseconds.
  • temporal maps to the TC39 Temporal API, preserving offset and sub-second precision, and requires the Temporal global 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.

SDKHow validation reaches the wireExtra step
GoGenerated MarshalJSON and UnmarshalJSON on each modelNone
JavaGenerated Jackson serializer and deserializer on each modelNone
PythonPydantic model validationUse the Pydantic data converter
TypeScriptGenerated mapper classesCall 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

caution

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() and getPriorityOrDefault().
  • 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 with value.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.

RESOURCES