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 →