Strict Output Validator — TypeScript 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 TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Pure logic for the Strict Output Validator tool (slug: strict-output-validator).
// 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.
// Rules (2026 OpenAI strict mode):
// R1 root must be type "object"
// 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.
export type StrictRule =
| 'root-not-object'
| 'missing-additional-properties'
| 'property-not-required'
| 'required-not-property'
| 'unsupported-type'
| 'unsupported-keyword'
| 'invalid-schema';
export interface StrictIssue {
path: string;
rule: StrictRule;
message: string;
}
export interface StrictReport {
ok: boolean;
issues: StrictIssue[];
counts: { objects: number; properties: number; enums: number };
}
/** Types strict mode supports. */
const SUPPORTED_TYPES = new Set(['object', 'array', 'string', 'number', 'integer', 'boolean']);
/** Keywords strict mode understands per-node. Everything else is flagged. */
const SUPPORTED_KEYWORDS = new Set([
'type',
'description',
'title',
'properties',
'required',
'additionalProperties',
'items',
'enum',
'const',
'anyOf',
'allOf', // accepted only as single-element; checked in the walker
'$ref',
'$defs',
'definitions',
'format',
'nullable',
'default',
]);
export function validateStrictSchema(input: unknown): StrictReport {
const issues: StrictIssue[] = [];
const counts = { objects: 0, properties: 0, enums: 0 };
let schema: unknown = input;
if (typeof input === 'string') {
try {
schema = JSON.parse(input);
} catch (e) {
return {
ok: false,
issues: [
{
path: '$',
rule: 'invalid-schema',
message: `Not valid JSON: ${e instanceof Error ? e.message : String(e)}`,
},
],
counts,
};
}
}
if (!isObj(schema)) {
return {
ok: false,
issues: [{ path: '$', rule: 'invalid-schema', message: 'Schema must be a JSON object.' }],
counts,
};
}
walk(schema as Record<string, unknown>, '$');
return { ok: issues.length === 0, issues, counts };
function isObj(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function unsupportedKeywords(node: Record<string, unknown>, path: string): void {
for (const key of Object.keys(node)) {
if (!SUPPORTED_KEYWORDS.has(key)) {
issues.push({
path,
rule: 'unsupported-keyword',
message: `"${key}" is not supported in strict mode — remove it or express the constraint another way.`,
});
}
}
}
function walk(node: Record<string, unknown>, path: string): void {
unsupportedKeywords(node, path);
const type = node.type;
// 'null' is only expressible inside a type array (the nullable form).
const isNullableForm = Array.isArray(type);
const typeList = isNullableForm ? type : typeof type === 'string' ? [type] : [];
for (const t of typeList) {
const supported = typeof t === 'string' && (SUPPORTED_TYPES.has(t) || (isNullableForm && t === 'null'));
if (!supported) {
issues.push({
path,
rule: 'unsupported-type',
message: `type ${JSON.stringify(t)} is not supported — strict mode allows object, array, string, number, integer, boolean (null only inside a type array).`,
});
}
}
// allOf is accepted only as a single-element wrapper.
if (Array.isArray(node.allOf) && node.allOf.length !== 1) {
issues.push({
path,
rule: 'unsupported-keyword',
message: 'allOf is supported only with exactly one subschema (use anyOf for unions).',
});
}
if (node.type === 'object' || node.properties !== undefined || node.required !== undefined) {
counts.objects += 1;
if (node.additionalProperties !== false) {
issues.push({
path,
rule: 'missing-additional-properties',
message: 'Object needs "additionalProperties": false — strict mode rejects open objects.',
});
}
const props = isObj(node.properties) ? (node.properties as Record<string, unknown>) : {};
const required = Array.isArray(node.required) ? (node.required as unknown[]) : [];
counts.properties += Object.keys(props).length;
for (const key of Object.keys(props)) {
if (!required.includes(key)) {
issues.push({
path: `${path}.required`,
rule: 'property-not-required',
message: `"${key}" is defined in properties but missing from required — strict mode requires every property.`,
});
}
}
for (const key of required) {
if (typeof key === 'string' && !(key in props)) {
issues.push({
path: `${path}.required`,
rule: 'required-not-property',
message: `"${key}" is required but has no definition in properties.`,
});
}
}
for (const [key, sub] of Object.entries(props)) {
if (isObj(sub)) walk(sub as Record<string, unknown>, `${path}.properties.${key}`);
}
}
if (isObj(node.items)) {
walk(node.items as Record<string, unknown>, `${path}.items`);
}
if (Array.isArray(node.enum)) counts.enums += 1;
for (const listKey of ['anyOf', 'oneOf', 'allOf'] as const) {
const list = node[listKey];
if (Array.isArray(list)) {
if (listKey === 'oneOf') {
issues.push({
path: `${path}.${listKey}`,
rule: 'unsupported-keyword',
message: 'oneOf is not supported — strict mode unions are expressed with anyOf.',
});
}
list.forEach((sub, i) => {
if (isObj(sub)) walk(sub as Record<string, unknown>, `${path}.${listKey}[${i}]`);
});
}
}
for (const defsKey of ['$defs', 'definitions'] as const) {
const defs = node[defsKey];
if (isObj(defs)) {
for (const [name, sub] of Object.entries(defs)) {
if (isObj(sub)) walk(sub as Record<string, unknown>, `${path}.${defsKey}.${name}`);
}
}
}
}
}
// Root-rule wrapper: the whole-report entry point that also enforces R1.
export function validateStrictRoot(input: unknown): StrictReport {
const report = validateStrictSchema(input);
let schema: unknown = input;
if (typeof input === 'string') {
try {
schema = JSON.parse(input);
} catch {
return report; // invalid-schema already reported
}
}
if (
typeof schema === 'object' &&
schema !== null &&
!Array.isArray(schema) &&
(schema as Record<string, unknown>).type !== 'object'
) {
report.issues.unshift({
path: '$',
rule: 'root-not-object',
message: 'The root schema must be type "object" — strict mode cannot return a bare scalar or array.',
});
report.ok = false;
}
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 →