Discriminated Unions
Type-safe handling of JSON with multiple possible shapes using a discriminator field.
For JSON Schema generation of unions, see Schema Generation. For streaming union responses from LLMs, see Streaming Validation and LLM Examples.
What Are Discriminated Unions?
Discriminated unions allow you to validate JSON payloads that can be one of several different types, where a specific field (the discriminator) determines which variant is present. This is common in APIs that send polymorphic data.
For example, a payment system might send different data structures depending on the payment method:
{ "type": "credit_card", "cardNumber": "4111111111111111", "cvc": "123" }
{ "type": "bank_transfer", "accountNumber": "12345", "routingNumber": "987654" }
{ "type": "digital_wallet", "walletId": "wallet_123", "provider": "apple_pay" }
A discriminated union automatically routes each JSON payload to the correct variant type and validates it accordingly.
Defining Variant Types
Each variant in your union should be a separate struct with appropriate validation constraints:
type CreditCard struct {
Type string `json:"type" pedantigo:"required"`
CardNumber string `json:"cardNumber" pedantigo:"required,pattern=^[0-9]{16}$"`
CVC string `json:"cvc" pedantigo:"required,pattern=^[0-9]{3}$"`
ExpiryDate string `json:"expiryDate" pedantigo:"required,pattern=^[0-9]{2}/[0-9]{2}$"`
}
type BankTransfer struct {
Type string `json:"type" pedantigo:"required"`
AccountNumber string `json:"accountNumber" pedantigo:"required,pattern=^[0-9]{10,12}$"`
RoutingNumber string `json:"routingNumber" pedantigo:"required,pattern=^[0-9]{9}$"`
AccountHolderName string `json:"accountHolderName" pedantigo:"required,min=2"`
}
type DigitalWallet struct {
Type string `json:"type" pedantigo:"required"`
WalletID string `json:"walletId" pedantigo:"required,min=1"`
Provider string `json:"provider" pedantigo:"required,enum=apple_pay|google_pay|paypal"`
}
Each variant struct:
- Must have a discriminator field (typically a
Typefield) that contains the variant identifier - Must have constraints - validation rules are applied per variant
- Can have its own validation logic - implement
Validatableinterface for cross-field checks
Creating a UnionValidator
Unlike the Simple API, discriminated unions require explicit creation with pedantigo.NewUnion():
validator, err := pedantigo.NewUnion[any](pedantigo.UnionOptions{
DiscriminatorField: "type",
Variants: []pedantigo.UnionVariant{
pedantigo.VariantFor[CreditCard]("credit_card"),
pedantigo.VariantFor[BankTransfer]("bank_transfer"),
pedantigo.VariantFor[DigitalWallet]("digital_wallet"),
},
})
if err != nil {
// Handle creation errors (invalid discriminator, duplicate variants, etc.)
log.Fatal(err)
}
UnionOptions
The UnionOptions struct configures union behavior:
type UnionOptions struct {
// DiscriminatorField is the JSON field name that determines which variant to use
DiscriminatorField string
// Variants is a slice of UnionVariant defining all possible types
Variants []UnionVariant
}
UnionVariant
Each variant is created with VariantFor[T]():
pedantigo.VariantFor[CreditCard]("credit_card")
The generic type parameter is the Go struct, and the string argument is the discriminator value to match in the JSON.
Unmarshaling Union Data
When you unmarshal JSON with a union validator, it automatically:
- Inspects the discriminator field value
- Selects the matching variant
- Validates the JSON against that variant's constraints
- Returns the validated variant as an
anytype
jsonData := []byte(`{
"type": "credit_card",
"cardNumber": "4111111111111111",
"cvc": "123",
"expiryDate": "12/25"
}`)
result, err := validator.Unmarshal(jsonData)
if err != nil {
// Handle validation errors
var ve *pedantigo.ValidationError
if errors.As(err, &ve) {
for _, fieldErr := range ve.Errors {
fmt.Printf("Field %s: %s\n", fieldErr.Field, fieldErr.Message)
}
}
return
}
// result is interface{}, need type assertion
payment := result.(CreditCard)
fmt.Printf("Processing card: %s\n", payment.CardNumber)
Type Assertion Pattern
After unmarshaling, use type assertion to access the specific variant:
switch payment := result.(type) {
case CreditCard:
fmt.Printf("Credit card ending in: %s\n", payment.CardNumber[len(payment.CardNumber)-4:])
case BankTransfer:
fmt.Printf("Bank transfer to account: %s\n", payment.AccountNumber)
case DigitalWallet:
fmt.Printf("Digital wallet: %s\n", payment.Provider)
}
Error Handling
Discriminated union errors include field path information:
jsonData := []byte(`{
"type": "credit_card",
"cardNumber": "invalid",
"cvc": "12"
}`)
_, err := validator.Unmarshal(jsonData)
if err != nil {
var ve *pedantigo.ValidationError
if errors.As(err, &ve) {
for _, fieldErr := range ve.Errors {
// Example: Field "cardNumber" error
fmt.Printf("Field %s: %s\n", fieldErr.Field, fieldErr.Message)
}
}
}
Common errors:
- Missing discriminator field - Union can't determine which variant to use
- Unknown discriminator value - No variant matches the provided value
- Validation errors - The selected variant fails its constraints
Validating Existing Variant Values
You can also validate already-instantiated variant structs:
card := CreditCard{
Type: "credit_card",
CardNumber: "4111111111111111",
CVC: "123",
ExpiryDate: "12/25",
}
// Validate the existing value
err := validator.Validate(card)
if err != nil {
// Handle validation errors
}
JSON Schema for Unions
Discriminated unions generate OpenAPI-compatible JSON Schema using oneOf with a discriminator:
schema := validator.Schema()
// This produces a JSON Schema like:
// {
// "oneOf": [
// { "$ref": "#/definitions/CreditCard" },
// { "$ref": "#/definitions/BankTransfer" },
// { "$ref": "#/definitions/DigitalWallet" }
// ],
// "discriminator": {
// "propertyName": "type",
// "mapping": {
// "credit_card": "#/definitions/CreditCard",
// "bank_transfer": "#/definitions/BankTransfer",
// "digital_wallet": "#/definitions/DigitalWallet"
// }
// }
// }
This schema can be published in API documentation (OpenAPI/Swagger) to inform clients about the possible union variants.
Cross-Field Validation in Variants
Each variant can implement the Validatable interface for cross-field checks:
type CreditCard struct {
Type string `json:"type" pedantigo:"required"`
CardNumber string `json:"cardNumber" pedantigo:"required"`
CVC string `json:"cvc" pedantigo:"required"`
ExpiryDate string `json:"expiryDate" pedantigo:"required"`
}
func (c CreditCard) Validate() error {
// Parse expiry date and check it hasn't passed
parts := strings.Split(c.ExpiryDate, "/")
if len(parts) != 2 {
return errors.New("expiryDate must be in MM/YY format")
}
month, err := strconv.Atoi(parts[0])
if err != nil || month < 1 || month > 12 {
return errors.New("expiryDate month must be 01-12")
}
year, err := strconv.Atoi(parts[1])
if err != nil {
return errors.New("expiryDate year is invalid")
}
currentYear := time.Now().Year() % 100
currentMonth := int(time.Now().Month())
if year < currentYear || (year == currentYear && month < currentMonth) {
return errors.New("card has expired")
}
return nil
}
Complete Example: Payment Processing
Here's a complete payment processing example:
package main
import (
"errors"
"fmt"
"pedantigo"
)
// Define payment method variants
type CreditCard struct {
Type string `json:"type" pedantigo:"required"`
CardNumber string `json:"cardNumber" pedantigo:"required,pattern=^[0-9]{16}$"`
CVC string `json:"cvc" pedantigo:"required,pattern=^[0-9]{3}$"`
ExpiryDate string `json:"expiryDate" pedantigo:"required"`
}
func (c CreditCard) Validate() error {
// Validate expiry date format
if len(c.ExpiryDate) != 5 || c.ExpiryDate[2] != '/' {
return errors.New("expiryDate must be in MM/YY format")
}
return nil
}
type BankTransfer struct {
Type string `json:"type" pedantigo:"required"`
AccountNumber string `json:"accountNumber" pedantigo:"required,pattern=^[0-9]{10,12}$"`
RoutingNumber string `json:"routingNumber" pedantigo:"required,pattern=^[0-9]{9}$"`
}
type DigitalWallet struct {
Type string `json:"type" pedantigo:"required"`
WalletID string `json:"walletId" pedantigo:"required,min=1"`
Provider string `json:"provider" pedantigo:"required,enum=apple_pay|google_pay|paypal"`
}
func main() {
// Create union validator once
validator, err := pedantigo.NewUnion[any](pedantigo.UnionOptions{
DiscriminatorField: "type",
Variants: []pedantigo.UnionVariant{
pedantigo.VariantFor[CreditCard]("credit_card"),
pedantigo.VariantFor[BankTransfer]("bank_transfer"),
pedantigo.VariantFor[DigitalWallet]("digital_wallet"),
},
})
if err != nil {
panic(err)
}
// Example 1: Valid credit card
creditCardJSON := []byte(`{
"type": "credit_card",
"cardNumber": "4111111111111111",
"cvc": "123",
"expiryDate": "12/25"
}`)
result, err := validator.Unmarshal(creditCardJSON)
if err != nil {
fmt.Printf("Credit card validation failed: %v\n", err)
return
}
if card, ok := result.(CreditCard); ok {
fmt.Printf("Processing credit card: %s\n", card.CardNumber)
}
// Example 2: Valid bank transfer
bankJSON := []byte(`{
"type": "bank_transfer",
"accountNumber": "12345678901",
"routingNumber": "987654321"
}`)
result, err = validator.Unmarshal(bankJSON)
if err != nil {
fmt.Printf("Bank transfer validation failed: %v\n", err)
return
}
if bank, ok := result.(BankTransfer); ok {
fmt.Printf("Processing bank transfer to account: %s\n", bank.AccountNumber)
}
// Example 3: Valid digital wallet
walletJSON := []byte(`{
"type": "digital_wallet",
"walletId": "wallet_abc123",
"provider": "apple_pay"
}`)
result, err = validator.Unmarshal(walletJSON)
if err != nil {
fmt.Printf("Digital wallet validation failed: %v\n", err)
return
}
if wallet, ok := result.(DigitalWallet); ok {
fmt.Printf("Processing digital wallet: %s (%s)\n", wallet.WalletID, wallet.Provider)
}
// Example 4: Invalid credit card (bad card number)
invalidJSON := []byte(`{
"type": "credit_card",
"cardNumber": "invalid",
"cvc": "123",
"expiryDate": "12/25"
}`)
_, err = validator.Unmarshal(invalidJSON)
if err != nil {
var ve *pedantigo.ValidationError
if errors.As(err, &ve) {
fmt.Println("Validation errors:")
for _, fieldErr := range ve.Errors {
fmt.Printf(" Field %s: %s\n", fieldErr.Field, fieldErr.Message)
}
}
}
}
Streaming Discriminated Unions
For LLM outputs or streaming APIs, you can use StreamParser with unions:
// Create stream parser for union types
parser := pedantigo.NewStreamUnionParser[any](pedantigo.UnionOptions{
DiscriminatorField: "type",
Variants: []pedantigo.UnionVariant{
pedantigo.VariantFor[CreditCard]("credit_card"),
pedantigo.VariantFor[BankTransfer]("bank_transfer"),
pedantigo.VariantFor[DigitalWallet]("digital_wallet"),
},
})
// Feed streaming data
parser.Feed(`{"type": "credit_card"`)
parser.Feed(`, "cardNumber": "411111`)
parser.Feed(`1111111111"`)
parser.Feed(`, "cvc": "123"`)
parser.Feed(`, "expiryDate": "12/25"}`)
// Get validated result
result, err := parser.Complete()
Best Practices
- Always set discriminator first in JSON - Some streaming scenarios require the discriminator field early
- Use consistent discriminator values - Document the exact values expected (e.g., "credit_card" vs "creditCard")
- Implement Validatable for complex variants - Cross-field validation catches logic errors
- Test all variants - Ensure each variant path is validated properly
- Document variants in API docs - Include the discriminator values and variant schemas
- Use type assertions carefully - Always check the type after unmarshaling, or use switch statements
Common Pitfalls
-
Missing discriminator field - If JSON lacks the discriminator field, you get a clear error:
discriminator field "type" is missing -
Unknown discriminator value - If the value doesn't match any variant:
unknown discriminator value "fish" for field "type" -
Forgetting type assertions -
Unmarshal()returnsany. You must cast:card := result.(CreditCard) -
Variant validation still applies - After type resolution, all constraints on that variant are checked. Invalid fields = validation error.
-
Discriminator position in streaming - For streaming JSON, ensure the discriminator field appears early in the JSON structure.
Lightweight Alternative with skip_unless
For scenarios where you don't need streaming support or JSON Schema oneOf generation, skip_unless provides an alternative to UnionValidator that keeps all variants in a single struct.
The Pattern
type TV struct {
Channel int `json:"channel" pedantigo:"required,min=1,max=999"`
}
type Fan struct {
Speed int `json:"speed" pedantigo:"required,min=1,max=5"`
}
type Suite struct {
SuiteType string `json:"suite_type" pedantigo:"required,oneof=tv fan"`
TV TV `json:"tv" pedantigo:"skip_unless=SuiteType tv"`
Fan Fan `json:"fan" pedantigo:"skip_unless=SuiteType fan"`
}
How It Works
skip_unless=FieldName valueskips ALL validation on a field when the condition is NOT met- When
SuiteTypeis"tv": TV is validated, Fan is completely skipped - When
SuiteTypeis"fan": Fan is validated, TV is completely skipped - Invalid
SuiteTypevalues are caught by theoneofconstraint
Example Usage
validator := pedantigo.New[Suite]()
// TV mode - TV is validated, Fan is skipped
tvData := Suite{
SuiteType: "tv",
TV: TV{Channel: 42},
Fan: Fan{Speed: 0}, // Would fail min=1, but is skipped
}
err := validator.Validate(&tvData) // ✓ Valid
// Fan mode - Fan is validated, TV is skipped
fanData := Suite{
SuiteType: "fan",
TV: TV{Channel: 0}, // Would fail min=1, but is skipped
Fan: Fan{Speed: 3},
}
err = validator.Validate(&fanData) // ✓ Valid
// TV mode with invalid TV - fails validation
invalidData := Suite{
SuiteType: "tv",
TV: TV{Channel: 0}, // Fails: min=1
Fan: Fan{Speed: 0},
}
err = validator.Validate(&invalidData) // ✗ Error: tv.channel must be at least 1
When to Use skip_unless vs UnionValidator
| Feature | skip_unless | UnionValidator |
|---|---|---|
| Single struct | ✓ | ✗ (separate variant types) |
| Setup complexity | Simple | Moderate |
JSON Schema oneOf | ✗ | ✓ |
| Streaming support | ✗ | ✓ |
| Type assertion needed | ✗ | ✓ |
| Multiple discriminator values | ✗ | ✓ |
Use skip_unless when:
- You want a simple, single-struct solution
- You don't need JSON Schema
oneOfgeneration - You don't need streaming validation
- Each discriminator value maps to specific fields in one struct
Use UnionValidator when:
- You need distinct variant types with their own methods
- You need JSON Schema
oneOfwith discriminator mapping - You're using streaming JSON (LLM outputs)
- You need the full type-safe union pattern
Key Differences from the Simple API
Discriminated unions cannot use the Simple API because:
- They return
anytype (needs type assertion) - They require explicit variant registration
- They need detailed configuration (discriminator field, variant mapping)
This is why pedantigo.NewUnion() is required instead of pedantigo.Unmarshal[T]().
See Also
- Validation Basics - Core validation concepts
- Streaming Validation - For LLM outputs and real-time data
- Cross-Field Validation - Validating relationships between fields