← Public packages
@charlesmatte/todos
Shared todo, task and reminder store that any of your Kody-connected agents can use.
src/lib/validate.ts
132 lines · 4.8 KB · TypeScript/**
* Strict runtime input checking for agent-facing exports.
* Unknown keys are rejected so a guessed key never silently changes intent.
*/
export type FieldSpec =
| { type: 'string'; required?: boolean; pattern?: RegExp; maxLength?: number; allowEmpty?: boolean }
| { type: 'integer'; required?: boolean; min?: number; max?: number }
| { type: 'boolean'; required?: boolean }
| { type: 'stringArray'; required?: boolean; maxItems?: number }
| { type: 'enum'; values: ReadonlyArray<string>; required?: boolean }
| { type: 'enumArray'; values: ReadonlyArray<string>; required?: boolean }
| { type: 'object'; required?: boolean }
| { type: 'array'; required?: boolean; maxItems?: number }
export type Spec = Record<string, FieldSpec>
export class InputError extends Error {
constructor(message: string) {
super(message)
this.name = 'TodosInputError'
}
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function describeSpec(spec: Spec): string {
return Object.entries(spec)
.map(([key, field]) => {
const type =
field.type === 'enum' || field.type === 'enumArray'
? `${field.type === 'enumArray' ? 'array of ' : ''}${field.values.map((v) => JSON.stringify(v)).join(' | ')}`
: field.type
return `${key}${field.required ? '' : '?'}: ${type}`
})
.join(', ')
}
/**
* Parse an export input object against a strict spec. Throws on unknown keys,
* missing required keys, or wrong types. Returns only keys that were provided.
*/
export function parseInput<T extends Record<string, unknown>>(
raw: unknown,
spec: Spec,
exportName: string,
): T {
const input = raw === undefined || raw === null ? {} : raw
if (!isRecord(input)) {
throw new InputError(`${exportName}: input must be a plain object. Accepted shape: { ${describeSpec(spec)} }`)
}
const unknownKeys = Object.keys(input).filter((key) => !(key in spec))
if (unknownKeys.length > 0) {
throw new InputError(
`${exportName}: unknown input key(s) ${unknownKeys.map((k) => JSON.stringify(k)).join(', ')}. ` +
`Accepted shape: { ${describeSpec(spec)} }`,
)
}
const out: Record<string, unknown> = {}
for (const [key, field] of Object.entries(spec)) {
const value = input[key]
if (value === undefined || value === null) {
if (field.required) {
throw new InputError(`${exportName}: missing required "${key}". Accepted shape: { ${describeSpec(spec)} }`)
}
continue
}
out[key] = checkField(exportName, key, field, value)
}
return out as T
}
function checkField(exportName: string, key: string, field: FieldSpec, value: unknown): unknown {
const fail = (expected: string): never => {
throw new InputError(`${exportName}: "${key}" must be ${expected}.`)
}
switch (field.type) {
case 'string': {
if (typeof value !== 'string') return fail('a string')
const trimmed = (value as string).trim()
if (!field.allowEmpty && trimmed.length === 0) return fail('a non-empty string')
if (field.maxLength && (value as string).length > field.maxLength) return fail(`at most ${field.maxLength} characters`)
if (field.pattern && !field.pattern.test(trimmed)) return fail(`a string matching ${String(field.pattern)}`)
return field.allowEmpty ? value : trimmed
}
case 'integer': {
if (typeof value !== 'number' || !Number.isInteger(value)) return fail('an integer')
const num = value as number
if (field.min !== undefined && num < field.min) return fail(`>= ${field.min}`)
if (field.max !== undefined && num > field.max) return fail(`<= ${field.max}`)
return num
}
case 'boolean': {
if (typeof value !== 'boolean') return fail('a boolean')
return value
}
case 'stringArray': {
if (!Array.isArray(value) || !value.every((item) => typeof item === 'string' && item.trim().length > 0)) {
return fail('an array of non-empty strings')
}
if (field.maxItems && (value as Array<string>).length > field.maxItems) return fail(`at most ${field.maxItems} items`)
return (value as Array<string>).map((item) => item.trim())
}
case 'enum': {
if (typeof value !== 'string' || !field.values.includes(value)) {
return fail(`one of ${field.values.map((v) => JSON.stringify(v)).join(', ')}`)
}
return value
}
case 'enumArray': {
const list = typeof value === 'string' ? [value] : value
if (!Array.isArray(list) || !list.every((item) => typeof item === 'string' && field.values.includes(item))) {
return fail(`one of (or an array of) ${field.values.map((v) => JSON.stringify(v)).join(', ')}`)
}
return list
}
case 'object': {
if (!isRecord(value)) return fail('an object')
return value
}
case 'array': {
if (!Array.isArray(value)) return fail('an array')
if (field.maxItems && value.length > field.maxItems) return fail(`at most ${field.maxItems} items`)
return value
}
default:
return value
}
}