Skip to content

Strict Output Validator — C# 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 C# 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: C# (.NET 8, zero dependencies beyond System.Text.Json)
// 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

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace CosmoDev.StrictOutputValidator;

public enum StrictRule
{
    RootNotObject,
    MissingAdditionalProperties,
    PropertyNotRequired,
    RequiredNotProperty,
    UnsupportedType,
    UnsupportedKeyword,
    InvalidSchema,
}

public sealed record StrictIssue(string Path, StrictRule Rule, string Message);

public sealed record StrictReport(
    bool Ok,
    IReadOnlyList<StrictIssue> Issues,
    long Objects,
    long Properties,
    long Enums);

public static class StrictOutputValidator
{
    private static readonly HashSet<string> SupportedTypes =
        new() { "object", "array", "string", "number", "integer", "boolean" };

    private static readonly HashSet<string> SupportedKeywords = new()
    {
        "type", "description", "title", "properties", "required",
        "additionalProperties", "items", "enum", "const", "anyOf", "allOf",
        "$ref", "$defs", "definitions", "format", "nullable", "default",
    };

    /// <summary>
    /// Validate a parsed JSON Schema node. Non-object input reports
    /// <c>invalid-schema</c> (the TS contract).
    /// </summary>
    public static StrictReport ValidateStrictSchema(JsonNode? schema)
    {
        var issues = new List<StrictIssue>();
        long objects = 0, properties = 0, enums = 0;

        if (schema is not JsonObject root)
        {
            issues.Add(new StrictIssue("$", StrictRule.InvalidSchema,
                "Schema must be a JSON object."));
            return new StrictReport(false, issues, 0, 0, 0);
        }
        Walk(root, "$");
        return new StrictReport(issues.Count == 0, issues, objects, properties, enums);

        void Walk(JsonObject node, string path)
        {
            // R5 keywords
            foreach (var key in node.Select(kv => kv.Key))
            {
                if (!SupportedKeywords.Contains(key))
                {
                    issues.Add(new StrictIssue(path, StrictRule.UnsupportedKeyword,
                        $"\"{key}\" is not supported in strict mode — remove it or express " +
                        "the constraint another way."));
                }
            }

            // R5 types ('null' only inside a type array).
            if (node["type"] is { } typeNode)
            {
                if (typeNode is JsonValue tv && tv.TryGetValue<string>(out var typeStr))
                {
                    if (!SupportedTypes.Contains(typeStr))
                    {
                        issues.Add(new StrictIssue(path, StrictRule.UnsupportedType,
                            $"type \"{typeStr}\" is not supported — strict mode allows object, " +
                            "array, string, number, integer, boolean (null only inside a type array)."));
                    }
                }
                else if (typeNode is JsonArray typeArr)
                {
                    foreach (var t in typeArr)
                    {
                        var s = (t as JsonValue)?.GetValue<string>();
                        var ok = s != null && (SupportedTypes.Contains(s) || s == "null");
                        if (!ok)
                        {
                            issues.Add(new StrictIssue(path, StrictRule.UnsupportedType,
                                "a type-array entry is not supported — strict mode allows " +
                                "object, array, string, number, integer, boolean, null."));
                        }
                    }
                }
            }

            // allOf is accepted only as a single-element wrapper.
            if (node["allOf"] is JsonArray allOf && allOf.Count != 1)
            {
                issues.Add(new StrictIssue(path, StrictRule.UnsupportedKeyword,
                    "allOf is supported only with exactly one subschema (use anyOf for unions)."));
            }

            // Object node: R2, R3, R4.
            var typeValue = (node["type"] as JsonValue)?.GetValue<string>();
            bool isObjectNode = typeValue == "object"
                || node.ContainsKey("properties")
                || node.ContainsKey("required");
            if (isObjectNode)
            {
                objects++;
                var ap = node["additionalProperties"];
                bool apFalse = ap is JsonValue b && b.TryGetValue<bool>(out var bv) && !bv;
                if (!apFalse)
                {
                    issues.Add(new StrictIssue(path, StrictRule.MissingAdditionalProperties,
                        "Object needs \"additionalProperties\": false — strict mode rejects " +
                        "open objects."));
                }
                var props = node["properties"] as JsonObject;
                var required = node["required"] as JsonArray;
                var reqKeys = required?.OfType<JsonValue>()
                    .Where(r => r.TryGetValue<string>(out _))
                    .Select(r => r.GetValue<string>())
                    .ToHashSet() ?? new HashSet<string>();
                if (props != null)
                {
                    properties += props.Count;
                    foreach (var key in props.Select(kv => kv.Key))
                    {
                        if (!reqKeys.Contains(key))
                        {
                            issues.Add(new StrictIssue(path + ".required",
                                StrictRule.PropertyNotRequired,
                                $"\"{key}\" is defined in properties but missing from " +
                                "required — strict mode requires every property."));
                        }
                    }
                    foreach (var key in reqKeys)
                    {
                        if (!props.ContainsKey(key))
                        {
                            issues.Add(new StrictIssue(path + ".required",
                                StrictRule.RequiredNotProperty,
                                $"\"{key}\" is required but has no definition in properties."));
                        }
                    }
                    foreach (var (key, sub) in props)
                    {
                        if (sub is JsonObject o) Walk(o, $"{path}.properties.{key}");
                    }
                }
                else
                {
                    foreach (var key in reqKeys)
                    {
                        issues.Add(new StrictIssue(path + ".required",
                            StrictRule.RequiredNotProperty,
                            $"\"{key}\" is required but has no definition in properties."));
                    }
                }
            }

            if (node["items"] is JsonObject items) Walk(items, path + ".items");
            if (node["enum"] is JsonArray) enums++;

            foreach (var listKey in new[] { "anyOf", "oneOf", "allOf" })
            {
                if (node[listKey] is JsonArray list)
                {
                    if (listKey == "oneOf")
                    {
                        issues.Add(new StrictIssue(path + "." + listKey,
                            StrictRule.UnsupportedKeyword,
                            "oneOf is not supported — strict mode unions are expressed with anyOf."));
                    }
                    for (int i = 0; i < list.Count; i++)
                    {
                        if (list[i] is JsonObject sub)
                        {
                            Walk(sub, $"{path}.{listKey}[{i}]");
                        }
                    }
                }
            }
            foreach (var defsKey in new[] { "$defs", "definitions" })
            {
                if (node[defsKey] is JsonObject defs)
                {
                    foreach (var (name, sub) in defs)
                    {
                        if (sub is JsonObject o) Walk(o, $"{path}.{defsKey}.{name}");
                    }
                }
            }
        }
    }

    /// <summary>
    /// The whole-report entry point: parse + validate + R1 (root must be
    /// type "object"). Input is a JSON string.
    /// </summary>
    public static StrictReport ValidateStrictRoot(string input)
    {
        JsonNode? schema;
        try
        {
            schema = JsonNode.Parse(input);
        }
        catch (JsonException e)
        {
            return new StrictReport(false,
                new[] { new StrictIssue("$", StrictRule.InvalidSchema,
                    $"Not valid JSON: {e.Message}") }, 0, 0, 0);
        }
        var report = ValidateStrictSchema(schema);
        if (schema is JsonObject root
            && !((root["type"] as JsonValue)?.GetValue<string>() == "object"))
        {
            var issues = new List<StrictIssue>
            {
                new("$", StrictRule.RootNotObject,
                    "The root schema must be type \"object\" — strict mode cannot return " +
                    "a bare scalar or array."),
            };
            issues.AddRange(report.Issues);
            return new StrictReport(false, issues, report.Objects, report.Properties,
                report.Enums);
        }
        return report;
    }
}

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 →