Typed Union Outputs ​
Use typed union outputs when one step can complete with one of several business outcomes and each outcome has a different contract.
The step still has one declared output type. That output can be a closed union whose variants map to Java sealed interfaces, protobuf oneof, and REST discriminator JSON.
Define the Contract ​
Declare the variant payloads as normal messages, then declare a top-level union:
version: 2
messages:
PaymentCaptured:
fields:
- number: 1
name: orderId
type: uuid
- number: 2
name: paymentId
type: uuid
PaymentRejected:
fields:
- number: 1
name: orderId
type: uuid
- number: 2
name: failureCode
type: string
unions:
PaymentOutcome:
variants:
captured:
type: PaymentCaptured
number: 1
rejected:
type: PaymentRejected
number: 2Use the union name as the step output:
steps:
- name: Capture Payment
service: com.example.payment.CapturePaymentService
cardinality: ONE_TO_ONE
input: com.example.domain.PaymentRequest
inputTypeName: PaymentRequest
output: com.example.domain.PaymentOutcome
outputTypeName: PaymentOutcomeFor protobuf-backed boundaries, provide normal mappers for each variant payload. TPF generates the union wrapper mapper and composes those variant mappers, so application code does not need to write or declare a PaymentOutcome mapper. The FUNCTION platform mode (over REST, gRPC, or LOCAL transport), checkpoint JSON, and REST boundaries use the sealed union type directly.
Implement the Domain Type ​
Model the Java output as a sealed interface:
public sealed interface PaymentOutcome
permits PaymentCaptured, PaymentRejected {
}
public record PaymentCaptured(UUID orderId, UUID paymentId)
implements PaymentOutcome {
}
public record PaymentRejected(UUID orderId, String failureCode)
implements PaymentOutcome {
}The service signature remains a normal one-output step:
@PipelineStep
@ApplicationScoped
public class CapturePaymentService
implements ReactiveService<PaymentRequest, PaymentOutcome> {
@Override
public Uni<PaymentOutcome> process(PaymentRequest request) {
return capture(request)
? Uni.createFrom().item(new PaymentCaptured(request.orderId(), newPaymentId()))
: Uni.createFrom().item(new PaymentRejected(request.orderId(), "PAYMENT_REJECTED"));
}
}Transport Shape ​
For gRPC, TPF generates a protobuf wrapper with oneof:
message PaymentOutcome {
oneof outcome {
PaymentCaptured captured = 1;
PaymentRejected rejected = 2;
}
}For REST and checkpoint JSON, use a discriminated JSON object:
{
"type": "captured",
"orderId": "11111111-1111-1111-1111-111111111111",
"paymentId": "22222222-2222-2222-2222-222222222222"
}The TPFGo example uses Jackson polymorphic annotations on the sealed interface and its variants to produce this shape. Framework-generated sealed-type scaffolding can provide that wiring later.
For gRPC, field-level mapping stays in ordinary variant mappers such as Mapper<PaymentCaptured, PipelineTypes.PaymentCaptured>. The generated union wrapper mapper only selects the protobuf oneof variant and delegates the payload conversion to those mappers.
Constraints ​
- Union names must not collide with message names or built-in semantic types.
- Each variant must reference a top-level message.
- Variant names and protobuf field numbers must be unique.
- A union can be used as a step input or output type.
- A union cannot be used as a field inside a normal message in this first version.
- TPF does not route variants automatically; downstream steps receive the union and handle it through normal polymorphic domain behavior.
TPFGo uses this shape in the payment capture pipeline, where PaymentOutcome replaces a status-field result record while keeping the pipeline linear.