Zig Type Mapping for Discriminators & Nullable Fields

Protocols & Interoperability advanced Building with AI ~30 min read

Overview

Once an OpenAPI specification has been parsed into a typed AST (see the companion topic on the two-phase parser), the next challenge is translating that AST into idiomatic Zig source code. This translation is far from a one-to-one mapping: OpenAPI’s type system includes concepts like nullable fields, optional properties, discriminated unions (oneOf with a discriminator), composed types (allOf/anyOf), and format-annotated primitives (string with format: "date-time") that have no direct Zig equivalent. The codegen module implements a principled type mapping layer that translates each OpenAPI type construct into the most natural Zig representation while preserving round-trip JSON serialization fidelity.

The hardest problems in this mapping involve nullable-versus-optional semantics and discriminated unions. In OpenAPI, a field can be required but nullable (must be present in JSON, but may be null), optional but non-nullable (may be absent, but if present must have a value), or optional and nullable (may be absent or null). Zig has a single ?T optional type that conflates absence and null. To faithfully represent all three states, the codegen introduces a Nullable(T) wrapper that distinguishes “field is present with value null” from “field is absent” — a critical distinction for APIs where the server interprets a missing field as “keep existing value” but an explicit null as “clear this field.”

For discriminated unions, OpenAPI’s oneOf with a discriminator object specifies that a single JSON field (the discriminator property) determines which of several sub-schemas the payload conforms to. The codegen maps this to a Zig union(enum) where each variant corresponds to a sub-schema, and emits custom jsonParse and jsonStringify methods that read the discriminator field to select the correct variant during deserialization. This approach leverages Zig’s comptime capabilities to generate efficient dispatch code without runtime reflection or type erasure.

Core Concepts

Primitive Type Mapping

OpenAPI primitive types map to Zig types via a translation table: string becomes []const u8, integer with format: "int32" becomes i32, integer with format: "int64" becomes i64, number becomes f64, boolean becomes bool, and string with format: "binary" becomes []const u8 (raw bytes). The format field refines the mapping — for example, string with format: "date-time" maps to a custom DateTime struct that wraps an ISO 8601 timestamp string with validation on parse.

Nullable vs. Optional Three-State Model

OpenAPI distinguishes three field states: required + non-nullable (field must be present with a value), optional + non-nullable (field may be absent but cannot be null), and required + nullable (field must be present but may be null). A fourth state — optional + nullable — is also possible. Zig’s ?T only encodes two states (present or absent). To represent all four, the codegen uses: bare T for required non-nullable, ?T = null for optional non-nullable, Nullable(T) for required nullable, and ?Nullable(T) = null for optional nullable. The Nullable(T) wrapper holds a ?T internally but customizes serialization to emit null rather than omitting the field.

Discriminated Union Mapping

An OpenAPI oneOf schema with a discriminator becomes a Zig union(enum). The discriminator’s propertyName identifies the JSON field that selects the variant, and the mapping object (or implicit schema name matching) associates each discriminator value with a sub-schema. The generated union has one variant per sub-schema, and the jsonParse implementation peeks at the discriminator field’s value before dispatching to the correct variant’s parser. This avoids speculative parsing (trying each variant until one succeeds) and provides O(1) dispatch via a comptime-generated string switch.

allOf Struct Embedding

OpenAPI’s allOf composition merges multiple schemas into a single type that has all combined properties. In Zig, this maps to a struct that embeds all parent types’ fields directly (not as nested sub-structs). The codegen flattens the property lists from all allOf sub-schemas, detects and reports conflicts, and emits a single struct definition. Required-field sets are unioned — a field is required in the composed type if any sub-schema marks it as required.

Comptime JSON Serialization

Zig’s comptime evaluation enables the codegen to emit jsonParse and jsonStringify methods that are resolved entirely at compile time. For each struct, the serializer iterates over fields at comptime, checks each field’s type for optional/nullable wrappers, and emits the appropriate serialization logic — calling .jsonStringify for nested types, emitting null for nullable values, and skipping absent optional fields. This produces zero-overhead serialization code equivalent to hand-written parsers.

How It Works

The type mapping module sits between the resolved AST and the Zig source emitter. It translates each AST Schema node into a Zig type declaration string and optionally a set of helper method implementations. The following examples demonstrate the key translation paths.

The Nullable(T) Wrapper

The Nullable type is a thin comptime-generic wrapper that distinguishes “explicitly null” from “absent” during JSON serialization. It is the cornerstone of the three-state field model.

// types/nullable.zig
const std = @import("std");

/// Nullable(T) represents a value that is explicitly present but may be null.
/// Unlike ?T, which Zig's std.json omits when null, Nullable(T) always
/// serializes — emitting JSON null when the inner value is null.
pub fn Nullable(comptime T: type) type {
    return struct {
        value: ?T,

        const Self = @This();

        pub const json_null: Self = .{ .value = null };

        pub fn init(val: T) Self {
            return .{ .value = val };
        }

        pub fn jsonParse(
            allocator: std.mem.Allocator,
            source: anytype,
            options: std.json.ParseOptions,
        ) !Self {
            // If the token is null, return our null sentinel.
            if (try source.peekNextTokenType() == .null) {
                _ = try source.next(); // consume the null token
                return json_null;
            }
            // Otherwise delegate to T's parser.
            const val = try std.json.innerParse(T, allocator, source, options);
            return Self.init(val);
        }

        pub fn jsonStringify(
            self: Self,
            options: std.json.StringifyOptions,
            writer: anytype,
        ) !void {
            if (self.value) |val| {
                try std.json.stringify(val, options, writer);
            } else {
                try writer.writeAll("null");
            }
        }
    };
}

Discriminated Union Code Generation

When the codegen encounters a oneOf schema with a discriminator, it emits a tagged union with a custom JSON parser that reads the discriminator field first. The following shows the generated output pattern for a schema with a type discriminator.

// Generated code for a discriminated union
// OpenAPI schema:
//   oneOf:
//     - $ref: '#/components/schemas/DogPet'
//     - $ref: '#/components/schemas/CatPet'
//   discriminator:
//     propertyName: pet_type
//     mapping:
//       dog: '#/components/schemas/DogPet'
//       cat: '#/components/schemas/CatPet'

pub const Pet = union(enum) {
    dog: DogPet,
    cat: CatPet,

    pub fn jsonParse(
        allocator: std.mem.Allocator,
        source: anytype,
        options: std.json.ParseOptions,
    ) !Pet {
        // Buffer the entire object so we can peek at the discriminator.
        const node = try std.json.Value.jsonParse(allocator, source, options);
        const obj = switch (node) {
            .object => |o| o,
            else => return error.UnexpectedToken,
        };

        // Read the discriminator field.
        const disc_val = obj.get("pet_type") orelse
            return error.MissingDiscriminator;
        const disc_str = switch (disc_val) {
            .string => |s| s,
            else => return error.InvalidDiscriminator,
        };

        // Dispatch to the correct variant.
        if (std.mem.eql(u8, disc_str, "dog")) {
            return .{ .dog = try std.json.parseFromValue(
                DogPet, allocator, node, options,
            ) };
        } else if (std.mem.eql(u8, disc_str, "cat")) {
            return .{ .cat = try std.json.parseFromValue(
                CatPet, allocator, node, options,
            ) };
        } else {
            return error.UnknownDiscriminatorValue;
        }
    }

    pub fn jsonStringify(
        self: Pet,
        options: std.json.StringifyOptions,
        writer: anytype,
    ) !void {
        switch (self) {
            .dog => |v| try std.json.stringify(v, options, writer),
            .cat => |v| try std.json.stringify(v, options, writer),
        }
    }
};

Struct Generation with Mixed Optional/Nullable Fields

The following shows how a schema with different field requirement and nullability combinations translates to Zig.

// OpenAPI schema:
//   type: object
//   required: [id, name, deleted_at]
//   properties:
//     id:
//       type: integer
//       format: int64
//     name:
//       type: string
//     bio:
//       type: string           # optional, non-nullable
//     deleted_at:
//       type: string
//       format: date-time
//       nullable: true          # required, nullable
//     avatar_url:
//       type: string
//       nullable: true          # optional, nullable

// Generated Zig struct:
const Nullable = @import("types/nullable.zig").Nullable;

pub const User = struct {
    id: i64,                          // required, non-nullable
    name: []const u8,                 // required, non-nullable
    bio: ?[]const u8 = null,          // optional, non-nullable
    deleted_at: Nullable(DateTime),   // required, nullable
    avatar_url: ?Nullable([]const u8) = null, // optional, nullable

    pub fn jsonStringify(
        self: User,
        options: std.json.StringifyOptions,
        writer: anytype,
    ) !void {
        try writer.writeByte('{');
        // Always emit required fields.
        try writer.writeAll("\"id\":");
        try std.json.stringify(self.id, options, writer);
        try writer.writeByte(',');
        try writer.writeAll("\"name\":");
        try std.json.stringify(self.name, options, writer);
        try writer.writeByte(',');
        // Required nullable: always emit, may be null.
        try writer.writeAll("\"deleted_at\":");
        try self.deleted_at.jsonStringify(options, writer);
        // Optional non-nullable: only emit if present.
        if (self.bio) |bio| {
            try writer.writeByte(',');
            try writer.writeAll("\"bio\":");
            try std.json.stringify(bio, options, writer);
        }
        // Optional nullable: only emit if the outer optional is set.
        if (self.avatar_url) |nullable_url| {
            try writer.writeByte(',');
            try writer.writeAll("\"avatar_url\":");
            try nullable_url.jsonStringify(options, writer);
        }
        try writer.writeByte('}');
    }
};

Practical Applications

The type mapping layer is exercised every time an API specification changes and the client SDK is regenerated. A concrete example is a document insertion endpoint, which accepts a JSON body with required fields (collection, document), optional fields (metadata, ttl), and nullable fields (parent_id — where null explicitly means “top-level document” rather than “inherit from context”). Without the three-state model, the generated client would be unable to distinguish between “caller did not specify parent_id” and “caller explicitly wants a top-level document,” leading to silent server-side behavior differences.

Discriminated unions are heavily used in event streaming APIs, where server-sent events carry different payload shapes depending on the event type. The generated StreamEvent union allows Zig client code to switch on event type with exhaustive pattern matching — the compiler rejects any handler that does not cover all event variants, providing a compile-time guarantee that new event types added to the API spec are handled before the code can ship.

The allOf flattening is used for query filter types, where base filter schemas (comparison operators, logical combinators) are composed with collection-specific field schemas. Flattening produces a single Zig struct per filter type that carries both the operator fields and the collection-specific field references, avoiding the ergonomic penalty of deeply nested structs that would result from a naive sub-struct embedding approach.

Typical Integration Points

Implementation Patterns

Type Name Sanitization

OpenAPI schema names may contain characters that are not valid Zig identifiers — hyphens, dots, spaces, or leading digits. The codegen sanitizes names by replacing hyphens and dots with underscores, removing spaces, and prefixing leading digits with an underscore. A collision-detection pass ensures that two distinct OpenAPI names do not map to the same Zig identifier, appending numeric suffixes when necessary.

Enum Generation from String Constraints

A string schema with an enum constraint maps to a Zig enum rather than a bare []const u8. Each allowed value becomes an enum variant (sanitized as above), and the codegen emits jsonParse/jsonStringify methods that convert between the enum tag and the original string value. This gives callers exhaustive switch coverage over all allowed values, and the parser rejects unknown strings at deserialization time rather than passing them through silently.

Recursive Schema Detection

Self-referential schemas (e.g., a TreeNode with children: []TreeNode) require special handling in Zig because struct fields cannot have the same type as the enclosing struct without indirection. The codegen detects recursive references during type mapping and inserts pointer indirection (*const TreeNode or []const *const TreeNode) at the point of recursion. The jsonParse implementation allocates recursive nodes on the arena allocator to avoid lifetime management issues.

Default Value Propagation

OpenAPI schemas can specify default values for properties. The codegen propagates these into Zig struct field defaults where the types are compatible — numeric and boolean defaults map directly, string defaults become comptime string literals, and enum defaults resolve to the corresponding enum tag. For complex types (objects, arrays), defaults are ignored and the field is treated as optional without a default, since Zig struct field defaults must be comptime-known.

Common Pitfalls

Conflating Nullable and Optional in Generated Types

The most common mistake when implementing OpenAPI-to-Zig codegen is using ?T for all non-required and nullable fields. This causes required-but-nullable fields to be omitted from serialized JSON when their value is null, violating the API contract. The fix is the Nullable(T) wrapper described above, which ensures that the field is always present in the serialized output even when its value is null.

Missing Discriminator Field in Serialized Output

When generating jsonStringify for a discriminated union, the emitter must include the discriminator field in the output even though it is not explicitly modeled as a field of the union variant’s struct. A common bug is to emit only the variant’s fields, omitting the discriminator property. The fix is to inject the discriminator key-value pair at the start of the serialized object, derived from the active union tag.

Integer Overflow on Unbounded integer Types

OpenAPI’s integer type without a format specifier has no defined size constraint — the value could be arbitrarily large. Mapping this to Zig’s i64 is a pragmatic choice, but it means that JSON payloads containing integers larger than 2^63-1 will cause a parse error. The codegen should document this limitation in a comment on the generated field, and consider offering a BigInt or []const u8 fallback for schemas known to carry unbounded integers.

allOf Flattening with Incompatible Required Sets

When flattening allOf, the codegen must union the required arrays from all sub-schemas. A subtle bug occurs when one sub-schema marks a field as optional and another marks it as required — the field must be required in the composed type. Failing to union required sets correctly results in generated structs where the field has a = null default, allowing callers to omit it even though the API expects it to be present.

Enum Value Case Sensitivity

OpenAPI enum values are case-sensitive strings, but Zig enum tags are identifiers that follow Zig naming conventions (typically snake_case). The codegen must maintain a mapping from the original string value to the sanitized enum tag and use this mapping in both jsonParse and jsonStringify. Using @tagName directly for serialization produces snake_case strings instead of the original casing, breaking API compatibility.

Further Reading

Further Reading