Skip to content

Strict Output Validator — Kotlin 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 Kotlin 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: Kotlin (JVM 17+, zero dependencies)
// Port of src/lib/strictOutputValidator.ts (the canonical TypeScript
// implementation). Uses a minimal sealed-class JSON DOM (kotlinx.serialization
// maps onto it directly if you have it).
// 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

import kotlin.collections.set

enum class StrictRule {
    ROOT_NOT_OBJECT, MISSING_ADDITIONAL_PROPERTIES, PROPERTY_NOT_REQUIRED,
    REQUIRED_NOT_PROPERTY, UNSUPPORTED_TYPE, UNSUPPORTED_KEYWORD, INVALID_SCHEMA,
}

data class StrictIssue(val path: String, val rule: StrictRule, val message: String)

data class StrictReport(
    val ok: Boolean,
    val issues: List<StrictIssue>,
    val objects: Long,
    val properties: Long,
    val enums: Long,
)

/** Minimal JSON value tree (the DOM the walker needs). */
sealed interface J
data class JObj(val entries: LinkedHashMap<String, J> = LinkedHashMap()) : J
data class JArr(val items: MutableList<J> = mutableListOf()) : J
data class JStr(val value: String) : J
data class JNum(val value: Double) : J
data class JBool(val value: Boolean) : J
object JNull : J

/** Types strict mode supports. */
private val SUPPORTED_TYPES =
    setOf("object", "array", "string", "number", "integer", "boolean")

/** Keywords strict mode understands per-node. Everything else is flagged. */
private val SUPPORTED_KEYWORDS = setOf(
    "type", "description", "title", "properties", "required",
    "additionalProperties", "items", "enum", "const", "anyOf", "allOf",
    "\$ref", "\$defs", "definitions", "format", "nullable", "default",
)

/** validateStrictSchema: walk the parsed schema and collect rule issues. */
fun validateStrictSchema(schema: J): StrictReport {
    val issues = mutableListOf<StrictIssue>()
    var objects = 0L
    var properties = 0L
    var enums = 0L

    if (schema !is JObj) {
        issues += StrictIssue("$", StrictRule.INVALID_SCHEMA, "Schema must be a JSON object.")
        return StrictReport(false, issues, 0, 0, 0)
    }

    fun walk(node: JObj, path: String) {
        // R5 keywords
        for (key in node.entries.keys) {
            if (key !in SUPPORTED_KEYWORDS) {
                issues += StrictIssue(
                    path, StrictRule.UNSUPPORTED_KEYWORD,
                    "\"$key\" is not supported in strict mode — remove it or express " +
                        "the constraint another way.",
                )
            }
        }

        // R5 types ('null' only inside a type array).
        when (val typeNode = node.entries["type"]) {
            is JStr -> if (typeNode.value !in SUPPORTED_TYPES) {
                issues += StrictIssue(
                    path, StrictRule.UNSUPPORTED_TYPE,
                    "type \"${typeNode.value}\" is not supported — strict mode allows " +
                        "object, array, string, number, integer, boolean (null only " +
                        "inside a type array).",
                )
            }
            is JArr -> for (t in typeNode.items) {
                val ok = t is JStr && (t.value in SUPPORTED_TYPES || t.value == "null")
                if (!ok) {
                    issues += StrictIssue(
                        path, StrictRule.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.
        val allOf = node.entries["allOf"]
        if (allOf is JArr && allOf.items.size != 1) {
            issues += StrictIssue(
                path, StrictRule.UNSUPPORTED_KEYWORD,
                "allOf is supported only with exactly one subschema (use anyOf for unions).",
            )
        }

        // Object node: R2, R3, R4.
        val typeStr = (node.entries["type"] as? JStr)?.value
        val isObjectNode = typeStr == "object"
            || "properties" in node.entries
            || "required" in node.entries
        if (isObjectNode) {
            objects++
            val ap = node.entries["additionalProperties"]
            val apFalse = ap is JBool && !ap.value
            if (!apFalse) {
                issues += StrictIssue(
                    path, StrictRule.MISSING_ADDITIONAL_PROPERTIES,
                    "Object needs \"additionalProperties\": false — strict mode rejects " +
                        "open objects.",
                )
            }
            val props = node.entries["properties"] as? JObj
            val required = node.entries["required"] as? JArr
            val reqKeys = required?.items
                ?.filterIsInstance<JStr>()
                ?.map { it.value }
                ?.toHashSet()
                ?: hashSetOf()
            if (props != null) {
                properties += props.entries.size
                for (key in props.entries.keys) {
                    if (key !in reqKeys) {
                        issues += StrictIssue(
                            "$path.required", StrictRule.PROPERTY_NOT_REQUIRED,
                            "\"$key\" is defined in properties but missing from required — " +
                                "strict mode requires every property.",
                        )
                    }
                }
                for (key in reqKeys) {
                    if (key !in props.entries) {
                        issues += StrictIssue(
                            "$path.required", StrictRule.REQUIRED_NOT_PROPERTY,
                            "\"$key\" is required but has no definition in properties.",
                        )
                    }
                }
                for ((key, sub) in props.entries) {
                    if (sub is JObj) walk(sub, "$path.properties.$key")
                }
            } else {
                for (key in reqKeys) {
                    issues += StrictIssue(
                        "$path.required", StrictRule.REQUIRED_NOT_PROPERTY,
                        "\"$key\" is required but has no definition in properties.",
                    )
                }
            }
        }

        (node.entries["items"] as? JObj)?.let { walk(it, "$path.items") }
        if (node.entries["enum"] is JArr) enums++

        for (listKey in listOf("anyOf", "oneOf", "allOf")) {
            val list = node.entries[listKey] as? JArr ?: continue
            if (listKey == "oneOf") {
                issues += StrictIssue(
                    "$path.$listKey", StrictRule.UNSUPPORTED_KEYWORD,
                    "oneOf is not supported — strict mode unions are expressed with anyOf.",
                )
            }
            list.items.forEachIndexed { i, sub ->
                if (sub is JObj) walk(sub, "$path.$listKey[$i]")
            }
        }
        for (defsKey in listOf("\$defs", "definitions")) {
            val defs = node.entries[defsKey] as? JObj ?: continue
            for ((name, sub) in defs.entries) {
                if (sub is JObj) walk(sub, "$path.$defsKey.$name")
            }
        }
    }

    walk(schema, "$")
    return StrictReport(issues.isEmpty(), issues, objects, properties, enums)
}

/**
 * The whole-report entry point: parse + validate + R1 (root must be
 * type "object"). `parse` builds the J tree from the JSON string.
 */
fun validateStrictRoot(input: String, parse: (String) -> J): StrictReport {
    val schema = try {
        parse(input)
    } catch (e: RuntimeException) {
        return StrictReport(
            false,
            listOf(StrictIssue("$", StrictRule.INVALID_SCHEMA, "Not valid JSON: ${e.message}")),
            0, 0, 0,
        )
    }
    val report = validateStrictSchema(schema)
    if (schema is JObj && (schema.entries["type"] as? JStr)?.value != "object") {
        val issues = buildList {
            add(StrictIssue(
                "$", StrictRule.ROOT_NOT_OBJECT,
                "The root schema must be type \"object\" — strict mode cannot return " +
                    "a bare scalar or array.",
            ))
            addAll(report.issues)
        }
        return report.copy(ok = false, issues = issues)
    }
    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 →