Skip to content

Strict Output Validator — Zig source

Check a JSON Schema against OpenAI structured-outputs strict mode rules — open objects, missing required keys, unsupported keywords — before the API rejects it. 100% client-side.

This is the Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Strict Output Validator — validate a JSON Schema against OpenAI's
// structured-outputs strict mode rules, so a schema fails HERE instead of
// at the API.
//
// Language: Zig (0.13+, stdlib only — std.json supplies the DOM)
// Port of src/lib/strictOutputValidator.ts (the canonical TypeScript
// implementation).
// Tool page: https://dev.cosmolabs.org/tools/strict-output-validator
//
// Rules (2026 OpenAI strict mode):
//   R1 root must be type "object"          (validateStrictRoot)
//   R2 every object node needs additionalProperties: false
//   R3 every key in properties must be in required
//   R4 required must not name keys absent from properties
//   R5 only supported type values / keywords may appear

const std = @import("std");

pub const Rule = enum {
    root_not_object,
    missing_additional_properties,
    property_not_required,
    required_not_property,
    unsupported_type,
    unsupported_keyword,
    invalid_schema,

    pub fn name(self: Rule) []const u8 {
        return switch (self) {
            .root_not_object => "root-not-object",
            .missing_additional_properties => "missing-additional-properties",
            .property_not_required => "property-not-required",
            .required_not_property => "required-not-property",
            .unsupported_type => "unsupported-type",
            .unsupported_keyword => "unsupported-keyword",
            .invalid_schema => "invalid-schema",
        };
    }
};

/// Raise for larger schemas; the walker saturates.
pub const MAX_ISSUES = 128;

pub const Issue = struct {
    path: []const u8, // allocated
    rule: Rule,
    message: []const u8, // allocated
};

pub const Report = struct {
    ok: bool = true,
    issues: []Issue = &.{},
    objects: i64 = 0,
    properties: i64 = 0,
    enums: i64 = 0,

    pub fn free(self: *Report, alloc: std.mem.Allocator) void {
        for (self.issues) |is| {
            alloc.free(is.path);
            alloc.free(is.message);
        }
        alloc.free(self.issues);
        self.issues = &.{};
    }
};

fn typeSupported(t: []const u8) bool {
    const supported = [_][]const u8{
        "object", "array", "string", "number", "integer", "boolean",
    };
    for (supported) |s| {
        if (std.mem.eql(u8, t, s)) return true;
    }
    return false;
}

fn keywordSupported(k: []const u8) bool {
    const supported = [_][]const u8{
        "type",         "description",       "title",    "properties",
        "required",     "additionalProperties", "items", "enum",
        "const",        "anyOf",             "allOf",    "$ref",
        "$defs",        "definitions",       "format",   "nullable",
        "default",
    };
    for (supported) |s| {
        if (std.mem.eql(u8, k, s)) return true;
    }
    return false;
}

const Walker = struct {
    alloc: std.mem.Allocator,
    issues: std.ArrayList(Issue),
    objects: i64 = 0,
    properties: i64 = 0,
    enums: i64 = 0,

    const ObjMap = std.StringArrayHashMap(std.json.Value);

    fn push(self: *Walker, path: []const u8, rule: Rule, comptime fmt: []const u8, args: anytype) !void {
        if (self.issues.items.len >= MAX_ISSUES) return;
        try self.issues.append(.{
            .path = try self.alloc.dupe(u8, path),
            .rule = rule,
            .message = try std.fmt.allocPrint(self.alloc, fmt, args),
        });
    }

    fn objOf(value: std.json.Value) ?*ObjMap {
        return switch (value) {
            .object => |*o| o,
            else => null,
        };
    }

    fn strOf(value: std.json.Value) ?[]const u8 {
        return switch (value) {
            .string => |s| s,
            else => null,
        };
    }

    fn boolOf(value: std.json.Value) ?bool {
        return switch (value) {
            .bool => |b| b,
            else => null,
        };
    }

    fn subPath(self: *Walker, path: []const u8, comptime fmt: []const u8, args: anytype) ![]const u8 {
        return std.fmt.allocPrint(self.alloc, "{s}" ++ fmt, .{path} ++ args);
    }

    fn walk(self: *Walker, node: *ObjMap, path: []const u8) !void {
        // R5 keywords
        for (node.keys()) |key| {
            if (!keywordSupported(key)) {
                try self.push(path, .unsupported_keyword,
                    "\"{s}\" is not supported in strict mode — remove it or express the constraint another way.", .{key});
            }
        }

        // R5 types ('null' only inside a type array).
        if (node.get("type")) |type_value| {
            switch (type_value) {
                .string => |t| {
                    if (!typeSupported(t)) {
                        try self.push(path, .unsupported_type,
                            "type \"{s}\" is not supported — strict mode allows object, array, string, number, integer, boolean (null only inside a type array).", .{t});
                    }
                },
                .array => |arr| {
                    for (arr.items) |t| {
                        const s = strOf(t) orelse "";
                        const ok = strOf(t) != null and (typeSupported(s) or std.mem.eql(u8, s, "null"));
                        if (!ok) {
                            try self.push(path, .unsupported_type,
                                "a type-array entry is not supported — strict mode allows object, array, string, number, integer, boolean, null.", .{});
                        }
                    }
                },
                else => {},
            }
        }

        // allOf is accepted only as a single-element wrapper.
        if (node.get("allOf")) |all_of| {
            if (all_of == .array and all_of.array.items.len != 1) {
                try self.push(path, .unsupported_keyword,
                    "allOf is supported only with exactly one subschema (use anyOf for unions).", .{});
            }
        }

        // Object node: R2, R3, R4.
        const type_str = if (node.get("type")) |t| strOf(t) else null;
        const is_object_node = (type_str != null and std.mem.eql(u8, type_str.?, "object"))
            or node.get("properties") != null
            or node.get("required") != null;
        if (is_object_node) {
            self.objects += 1;
            const ap = node.get("additionalProperties");
            const ap_false = ap != null and boolOf(ap.?) == false;
            if (!ap_false) {
                try self.push(path, .missing_additional_properties,
                    "Object needs \"additionalProperties\": false — strict mode rejects open objects.", .{});
            }
            const props: ?*ObjMap = if (node.get("properties")) |p| objOf(p) else null;
            var req_keys: std.ArrayList([]const u8) = .init(self.alloc);
            defer req_keys.deinit();
            if (node.get("required")) |r| {
                if (r == .array) {
                    for (r.array.items) |item| {
                        if (strOf(item)) |s| try req_keys.append(s);
                    }
                }
            }
            if (props) |p| {
                self.properties += @intCast(p.count());
                for (p.keys()) |key| {
                    var found = false;
                    for (req_keys.items) |rk| {
                        if (std.mem.eql(u8, rk, key)) {
                            found = true;
                            break;
                        }
                    }
                    if (!found) {
                        const sub = try self.subPath(path, ".required", .{});
                        defer self.alloc.free(sub);
                        try self.push(path, .property_not_required,
                            "\"{s}\" is defined in properties but missing from required — strict mode requires every property.", .{key});
                    }
                }
                for (req_keys.items) |rk| {
                    if (p.get(rk) == null) {
                        try self.push(path, .required_not_property,
                            "\"{s}\" is required but has no definition in properties.", .{rk});
                    }
                }
                var it = p.iterator();
                while (it.next()) |entry| {
                    if (objOf(entry.value_ptr.*)) |sub| {
                        const sp = try self.subPath(path, ".properties.{s}", .{entry.key_ptr.*});
                        try self.walk(sub, sp);
                    }
                }
            } else {
                for (req_keys.items) |rk| {
                    try self.push(path, .required_not_property,
                        "\"{s}\" is required but has no definition in properties.", .{rk});
                }
            }
        }

        if (node.get("items")) |items| {
            if (objOf(items)) |sub| {
                const sp = try self.subPath(path, ".items", .{});
                try self.walk(sub, sp);
            }
        }
        if (node.get("enum")) |e| {
            if (e == .array) self.enums += 1;
        }

        inline for (.{ "anyOf", "oneOf", "allOf" }) |list_key| {
            if (node.get(list_key)) |list| {
                if (list == .array) {
                    if (comptime std.mem.eql(u8, list_key, "oneOf")) {
                        try self.push(path, .unsupported_keyword,
                            "oneOf is not supported — strict mode unions are expressed with anyOf.", .{});
                    }
                    for (list.array.items, 0..) |sub_value, i| {
                        if (objOf(sub_value)) |sub| {
                            const sp = try self.subPath(path, "." ++ list_key ++ "[{d}]", .{i});
                            try self.walk(sub, sp);
                        }
                    }
                }
            }
        }
        inline for (.{ "$defs", "definitions" }) |defs_key| {
            if (node.get(defs_key)) |defs| {
                if (objOf(defs)) |d| {
                    var it = d.iterator();
                    while (it.next()) |entry| {
                        if (objOf(entry.value_ptr.*)) |sub| {
                            const sp = try self.subPath(path, "." ++ defs_key ++ ".{s}", .{entry.key_ptr.*});
                            try self.walk(sub, sp);
                        }
                    }
                }
            }
        }
    }
};

/// validateStrictSchema over a parsed std.json object.
pub fn validateStrictSchema(alloc: std.mem.Allocator, schema: std.json.Value) !Report {
    var w: Walker = .{ .alloc = alloc, .issues = .init(alloc) };
    errdefer {
        for (w.issues.items) |is| {
            alloc.free(is.path);
            alloc.free(is.message);
        }
        w.issues.deinit();
    }
    const root = Walker.objOf(schema) orelse {
        try w.push("$", .invalid_schema, "Schema must be a JSON object.", .{});
        var rep: Report = .{ .ok = false };
        rep.issues = try w.issues.toOwnedSlice();
        return rep;
    };
    try w.walk(root, "$");
    var rep: Report = .{
        .ok = w.issues.items.len == 0,
        .objects = w.objects,
        .properties = w.properties,
        .enums = w.enums,
    };
    rep.issues = try w.issues.toOwnedSlice();
    return rep;
}

/// The whole-report entry point: parse + validate + R1 (root must be
/// type "object"). `input` is a JSON string.
pub fn validateStrictRoot(alloc: std.mem.Allocator, input: []const u8) !Report {
    const parsed = std.json.parseFromSlice(std.json.Value, alloc, input, .{}) catch {
        var w: Walker = .{ .alloc = alloc, .issues = .init(alloc) };
        try w.push("$", .invalid_schema, "Not valid JSON.", .{});
        var rep: Report = .{ .ok = false };
        rep.issues = try w.issues.toOwnedSlice();
        return rep;
    };
    defer parsed.deinit();
    var rep = try validateStrictSchema(alloc, parsed.value);
    if (Walker.objOf(parsed.value)) |root| {
        const t = root.get("type");
        const is_object = t != null and Walker.strOf(t.?) != null
            and std.mem.eql(u8, Walker.strOf(t.?).?, "object");
        if (!is_object) {
            var issues: std.ArrayList(Issue) = .init(alloc);
            defer issues.deinit();
            try issues.append(.{
                .path = try alloc.dupe(u8, "$"),
                .rule = .root_not_object,
                .message = try alloc.dupe(u8,
                    "The root schema must be type \"object\" — strict mode cannot return a bare scalar or array."),
            });
            try issues.appendSlice(rep.issues);
            alloc.free(rep.issues);
            rep.issues = try issues.toOwnedSlice();
            rep.ok = false;
        }
    }
    return rep;
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →