Skip to content

Strict Output Validator — Go 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 Go implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Package strictoutputvalidator is the Go twin of CosmoDev's
// src/lib/strictOutputValidator.ts (dual source: the web lib is TypeScript,
// the CLI lib is Go — kept in lock-step). Pure + deterministic, never
// panics. It validates a JSON Schema against the structural rules OpenAI's
// structured-outputs strict mode enforces, so a schema fails HERE instead
// of at the API. The table-driven tests in strictoutputvalidator_test.go
// share their vectors with src/lib/strictOutputValidator.test.ts so the two
// implementations are held to the same contract.
//
// Rules (2026 OpenAI strict mode), mirrored from the TS lib:
//
//	R1 root must be type "object" (enforced by ValidateStrictRoot)
//	R2 every object node needs additionalProperties: false
//	R3 every key in properties must be listed in required (no optional keys)
//	R4 required must not name keys absent from properties
//	R5 only the supported type values / keywords may appear
//
// The keyword allowlist is conservative: keywords OpenAI documents as
// unsupported are flagged so the verdict is actionable, not just binary.
//
// Determinism note: the TS lib iterates object keys in insertion (document)
// order; Go maps are unordered, so this twin iterates keys in sorted order.
// The issue SET is identical for any schema; only the order of same-node
// unknown-keyword / property batches can differ from the TS lib.
package strictoutputvalidator

import (
	"encoding/json"
	"fmt"
	"sort"
)

// StrictRule names one strict-mode violation. Mirrors the StrictRule union
// in src/lib/strictOutputValidator.ts.
type StrictRule string

const (
	RuleRootNotObject               StrictRule = "root-not-object"
	RuleMissingAdditionalProperties StrictRule = "missing-additional-properties"
	RulePropertyNotRequired         StrictRule = "property-not-required"
	RuleRequiredNotProperty         StrictRule = "required-not-property"
	RuleUnsupportedType             StrictRule = "unsupported-type"
	RuleUnsupportedKeyword          StrictRule = "unsupported-keyword"
	RuleInvalidSchema               StrictRule = "invalid-schema"
)

// StrictIssue is one rule violation at one JSON path. Mirrors StrictIssue.
type StrictIssue struct {
	Path    string     `json:"path"`
	Rule    StrictRule `json:"rule"`
	Message string     `json:"message"`
}

// Counts tallies the schema shape the walker saw. Mirrors the inline
// counts object of StrictReport.
type Counts struct {
	Objects    int `json:"objects"`
	Properties int `json:"properties"`
	Enums      int `json:"enums"`
}

// StrictReport is the full validation verdict. Mirrors StrictReport; Issues
// is never nil (the TS lib always yields an array).
type StrictReport struct {
	OK     bool          `json:"ok"`
	Issues []StrictIssue `json:"issues"`
	Counts Counts        `json:"counts"`
}

// supportedTypes are the types strict mode supports. Mirrors SUPPORTED_TYPES.
var supportedTypes = map[string]bool{
	"object":  true,
	"array":   true,
	"string":  true,
	"number":  true,
	"integer": true,
	"boolean": true,
}

// supportedKeywords are the keywords strict mode understands per-node.
// Everything else is flagged. Mirrors SUPPORTED_KEYWORDS (allOf is accepted
// only as a single-element wrapper — checked in the walker).
var supportedKeywords = map[string]bool{
	"type":                 true,
	"description":          true,
	"title":                true,
	"properties":           true,
	"required":             true,
	"additionalProperties": true,
	"items":                true,
	"enum":                 true,
	"const":                true,
	"anyOf":                true,
	"allOf":                true, // accepted only as single-element; checked in the walker
	"$ref":                 true,
	"$defs":                true,
	"definitions":          true,
	"format":               true,
	"nullable":             true,
	"default":              true,
}

// walker carries the accumulated issues and counts through the recursive
// schema walk, mirroring the closures in validateStrictSchema().
type walker struct {
	issues []StrictIssue
	counts Counts
}

// ValidateStrictSchema validates a JSON Schema (given as an already-parsed
// object — map[string]any — or as a JSON string to parse) against OpenAI's
// structured-outputs strict-mode rules R2–R5. It is the twin of
// validateStrictSchema() in src/lib/strictOutputValidator.ts and never
// panics: malformed input yields an invalid-schema issue, not a crash.
func ValidateStrictSchema(input any) StrictReport {
	w := &walker{issues: []StrictIssue{}}

	schema := input
	if s, ok := input.(string); ok {
		// The TS lib formats `e instanceof Error ? e.message : String(e)`;
		// Go's json.Unmarshal only ever returns error values, so err.Error()
		// covers both branches.
		if err := json.Unmarshal([]byte(s), &schema); err != nil {
			return StrictReport{
				OK: false,
				Issues: []StrictIssue{{
					Path:    "$",
					Rule:    RuleInvalidSchema,
					Message: "Not valid JSON: " + err.Error(),
				}},
				Counts: w.counts,
			}
		}
	}
	node, ok := schema.(map[string]any)
	if !ok {
		return StrictReport{
			OK: false,
			Issues: []StrictIssue{{
				Path:    "$",
				Rule:    RuleInvalidSchema,
				Message: "Schema must be a JSON object.",
			}},
			Counts: w.counts,
		}
	}

	w.walk(node, "$")
	return StrictReport{OK: len(w.issues) == 0, Issues: w.issues, Counts: w.counts}
}

// ValidateStrictRoot is the whole-report entry point: ValidateStrictSchema
// plus rule R1 (the root schema must be type "object"). It is the twin of
// validateStrictRoot() in src/lib/strictOutputValidator.ts.
func ValidateStrictRoot(input any) StrictReport {
	report := ValidateStrictSchema(input)

	schema := input
	if s, ok := input.(string); ok {
		if err := json.Unmarshal([]byte(s), &schema); err != nil {
			return report // invalid-schema already reported
		}
	}
	if m, ok := schema.(map[string]any); ok {
		if t, _ := m["type"].(string); t != "object" {
			report.Issues = append([]StrictIssue{{
				Path:    "$",
				Rule:    RuleRootNotObject,
				Message: `The root schema must be type "object" — strict mode cannot return a bare scalar or array.`,
			}}, report.Issues...)
			report.OK = false
		}
	}
	return report
}

// walk applies every rule to one node, then recurses into sub-schemas.
// Mirrors walk() in the TS lib, in the same order: unknown keywords → type
// checks → allOf arity → object rules → items → enum count → anyOf/oneOf/
// allOf lists → $defs/definitions.
func (w *walker) walk(node map[string]any, path string) {
	w.unsupportedKeywords(node, path)

	// 'null' is only expressible inside a type array (the nullable form).
	typ := node["type"]
	typeArr, isNullableForm := typ.([]any)
	var typeList []any
	if isNullableForm {
		typeList = typeArr
	} else if s, ok := typ.(string); ok {
		typeList = []any{s}
	}
	for _, t := range typeList {
		supported := false
		if s, ok := t.(string); ok {
			supported = supportedTypes[s] || (isNullableForm && s == "null")
		}
		if !supported {
			w.issues = append(w.issues, StrictIssue{
				Path:    path,
				Rule:    RuleUnsupportedType,
				Message: fmt.Sprintf("type %s is not supported — strict mode allows object, array, string, number, integer, boolean (null only inside a type array).", jsonStringify(t)),
			})
		}
	}

	// allOf is accepted only as a single-element wrapper.
	if allOf, ok := node["allOf"].([]any); ok && len(allOf) != 1 {
		w.issues = append(w.issues, StrictIssue{
			Path:    path,
			Rule:    RuleUnsupportedKeyword,
			Message: "allOf is supported only with exactly one subschema (use anyOf for unions).",
		})
	}

	typeIsObject := false
	if s, ok := node["type"].(string); ok && s == "object" {
		typeIsObject = true
	}
	_, hasProps := node["properties"]
	_, hasRequired := node["required"]
	if typeIsObject || hasProps || hasRequired {
		w.counts.Objects++
		// A missing key unmarshals to nil, which != false — exactly like
		// the TS `node.additionalProperties !== false` check.
		if node["additionalProperties"] != false {
			w.issues = append(w.issues, StrictIssue{
				Path:    path,
				Rule:    RuleMissingAdditionalProperties,
				Message: `Object needs "additionalProperties": false — strict mode rejects open objects.`,
			})
		}
		props := map[string]any{}
		if p, ok := node["properties"].(map[string]any); ok {
			props = p
		}
		var required []any
		if r, ok := node["required"].([]any); ok {
			required = r
		}
		w.counts.Properties += len(props)
		for _, key := range sortedKeys(props) {
			if !containsString(required, key) {
				w.issues = append(w.issues, StrictIssue{
					Path:    path + ".required",
					Rule:    RulePropertyNotRequired,
					Message: fmt.Sprintf("%q is defined in properties but missing from required — strict mode requires every property.", key),
				})
			}
		}
		for _, key := range required {
			if s, ok := key.(string); ok {
				if _, inProps := props[s]; !inProps {
					w.issues = append(w.issues, StrictIssue{
						Path:    path + ".required",
						Rule:    RuleRequiredNotProperty,
						Message: fmt.Sprintf("%q is required but has no definition in properties.", s),
					})
				}
			}
		}
		for _, key := range sortedKeys(props) {
			if sub, ok := props[key].(map[string]any); ok {
				w.walk(sub, path+".properties."+key)
			}
		}
	}

	if items, ok := node["items"].(map[string]any); ok {
		w.walk(items, path+".items")
	}
	if _, ok := node["enum"].([]any); ok {
		w.counts.Enums++
	}
	for _, listKey := range []string{"anyOf", "oneOf", "allOf"} {
		list, ok := node[listKey].([]any)
		if !ok {
			continue
		}
		if listKey == "oneOf" {
			w.issues = append(w.issues, StrictIssue{
				Path:    path + "." + listKey,
				Rule:    RuleUnsupportedKeyword,
				Message: "oneOf is not supported — strict mode unions are expressed with anyOf.",
			})
		}
		for i, sub := range list {
			if m, ok := sub.(map[string]any); ok {
				w.walk(m, fmt.Sprintf("%s.%s[%d]", path, listKey, i))
			}
		}
	}
	for _, defsKey := range []string{"$defs", "definitions"} {
		defs, ok := node[defsKey].(map[string]any)
		if !ok {
			continue
		}
		for _, name := range sortedKeys(defs) {
			if sub, ok := defs[name].(map[string]any); ok {
				w.walk(sub, path+"."+defsKey+"."+name)
			}
		}
	}
}

// unsupportedKeywords flags every key of node that is not in the strict-mode
// allowlist. Mirrors unsupportedKeywords() in the TS lib; keys are visited
// in sorted order because Go map iteration is unordered.
func (w *walker) unsupportedKeywords(node map[string]any, path string) {
	for _, key := range sortedKeys(node) {
		if !supportedKeywords[key] {
			w.issues = append(w.issues, StrictIssue{
				Path:    path,
				Rule:    RuleUnsupportedKeyword,
				Message: fmt.Sprintf("%q is not supported in strict mode — remove it or express the constraint another way.", key),
			})
		}
	}
}

// containsString reports whether list holds the exact string key. Mirrors
// required.includes(key) — non-string members never match.
func containsString(list []any, key string) bool {
	for _, v := range list {
		if s, ok := v.(string); ok && s == key {
			return true
		}
	}
	return false
}

// sortedKeys returns m's keys in sorted order (Go maps are unordered).
func sortedKeys(m map[string]any) []string {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	return keys
}

// jsonStringify renders v the way TS JSON.stringify does for the values the
// walker meets (strings, numbers, arrays, objects). Unreachable for
// JSON-derived values, the %v fallback keeps it panic-free for any input.
func jsonStringify(v any) string {
	b, err := json.Marshal(v)
	if err != nil {
		return fmt.Sprintf("%v", v)
	}
	return string(b)
}

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 →