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 →