Back
tech
4/22/2025

Tagged Unions in Zig - Real World Example

Imagine you're building an e-commerce platform where users can pay with credit cards, PayPal, or even cryptocurrency. Each payment method requires different data—credit cards need a card number and CVV, while crypto payments need a wallet address and coin type. How do you model this in a type-safe, efficient way? Enter tagged unions in Zig. In this post, we'll explore what tagged unions are, why they're useful, and how to declare them, using a practical payment system example to bring it all to life.

What even is a Tagged Union?

A tagged union, sometimes called a "sum type" or "discriminated union," is a data structure that can hold one of several different types of values, but only one at a time. The "tag" is an identifier (usually an enum) that tells you which type of value is currently stored. Think of it like a box that can contain either a credit card, a PayPal transaction, or a crypto payment—but the tag on the box tells you exactly what's inside. In Zig, tagged unions are powerful because they combine type safety with memory efficiency. Unlike a generic void* pointer or a bulky struct that reserves space for every possible field, a tagged union only stores the data for the active variant, preventing wasted memory or unsafe casts.

For example, in our payment system, a tagged union lets us store either a CreditCard, PayPal, or Crypto struct, with the tag ensuring we always know which one we're dealing with.

Real-World Example: Payment System

const std = @import("std");

const PaymentType = enum {
    credit_card,
    paypal,
    crypto,
};

const CreditCard = struct {
    card_number: *const [16:0]u8,
    expiration_date: *const [5:0]u8,
    cvv: *const [3:0]u8,
};

const Paypal = struct {
    email: []const u8,
    transaction_id: []const u8,
};

const Crypto = struct {
    wallet_address: []const u8,
    transaction_hash: []const u8,
};

// This is our tagged union, you can view it as:
//
// "A union that can only have either credit_card, paypal or crypto as its 'active' field.
// And when that field is active it will be of type 'CreditCard', 'Paypal' or 'Crypto' respectively."
const PaymentDetails = union(PaymentType) {
    credit_card: CreditCard,
    paypal: Paypal,
    crypto: Crypto,
};

const Payment = struct {
    amount: f64,
    currency: []const u8,
    details: PaymentDetails,

    pub fn process(self: Payment) !void {
        std.debug.print("Processing payment of {d} {s}\n", .{self.amount, self.currency});

        // Switches are almost always associated with Tagged Unions
        // it's how you can determine which 'field' of the Tagged Union
        // is active and makes it easy to capture the value of the active field.
        switch (self.details) {
            .credit_card => |cc| {
                std.debug.print("Processing Credit Card payment with card number ending in: {s}\n", .{cc.card_number[cc.card_number.len - 4 ..]});
            },
            .paypal => |pp| {
                std.debug.print("Processing Paypal payment with email: {s}\n", .{pp.email});
            },
            .crypto => |crypto| {
                std.debug.print("Processing Crypto payment with wallet address: {s}\n", .{crypto.wallet_address});
            },
        }
    }
};

pub fn main() !void {
    const credit_card_payment = Payment{
        .amount = 100.0,
        .currency = "USD",
        .details = PaymentDetails{
            .credit_card = CreditCard{
                .card_number = "1234567812345678",
                .expiration_date = "12/25",
                .cvv = "123",
            },
        },
    };

    const paypal_payment = Payment{
        .amount = 49.99,
        .currency = "USD",
        .details = PaymentDetails{
            .paypal = Paypal{
                .email = "[email protected]",
                .transaction_id = "TX123456789",
            }
        }
    };

    const crypto_payment = Payment{
        .amount = 0.001,
        .currency = "BTC",
        .details = PaymentDetails{
            .crypto = Crypto{
                .wallet_address = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
                .transaction_hash = "abc123xyz456",
            }
        }
    };

    try credit_card_payment.process();
    try paypal_payment.process();
    try crypto_payment.process();
}

In the code above, we define a PaymentDetails tagged union that can hold either a CreditCard, Paypal, or Crypto struct. The Payment struct contains an amount, a currency, and the details field, which is our tagged union. The process method uses a switch statement to determine which type of payment is being processed, and it safely accesses the corresponding fields based on the active variant.

The ability to only have the Payment struct, but use a tagged union to alter behavior based on the PaymentDetails is a powerful feature of Zig. Note that you'll only ever define one of the fields, for example, this won't work:

const payment_details = PaymentDetails{
    .crypto = Crypto{...},
    .credit_card = CreditCard{...}, // Error, only one active field allowed
};

If you're from TypeScript land, imagine that the details (i.e. PaymentDetails) field of the Payment struct is a type like:

type PaymentDetails = 
    | { type: "credit_card", card_number: string, expiration_date: string, cvv: string }
    | { type: "paypal", email: string, transaction_id: string }
    | { type: "crypto", wallet_address: string, transaction_hash: string };

In Zig it's not possible to have such a type, but you can use tagged unions to achieve the same thing.

Why use Tagged Unions?

Tagged unions shine in scenarios where you need to model data that can take on one of several distinct forms. Here’s why you’d reach for them:

  • Type Safety: The tag ensures you can’t accidentally access a PayPal email as if it were a credit card number. Zig’s compiler catches these mistakes at compile time.
  • Memory Efficiency: Only the active variant’s data is stored, unlike a struct that might reserve space for all possible fields.
  • Expressive Code: Tagged unions make your intent clear. You’re saying, “This data can be one of these specific types, and here’s how to handle each case.”
  • Pattern Matching: Zig’s switch statement pairs beautifully with tagged unions, letting you handle each variant cleanly and exhaustively.

In our e-commerce example, a tagged union is perfect for representing different payment methods. It ensures we process a credit card payment with the right data (like CVV) and a crypto payment with its own data (like wallet address), without mixing them up or wasting memory.

Conclusion

Tagged unions in Zig are a powerful tool for modeling data that can take on one of several forms. They provide type safety, memory efficiency, and expressive code, making them ideal for scenarios like our e-commerce payment system. By using tagged unions, you can ensure that your code is both safe and efficient, while also being easy to read and maintain.

Interested in working with me? Reach out here