Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Add simple JSON schema / validation helpers
  • Loading branch information
mbg committed Apr 25, 2026
commit 243c274daf79e2158519c66f93640001e92c4699
46 changes: 46 additions & 0 deletions src/json/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import test from "ava";

import { setupTests } from "../testing-utils";

import * as json from ".";

setupTests(test);

const testSchema = {
requiredKey: json.string,
};

const optionalSchema = {
optionalKey: json.optional(json.string),
};

test("validateSchema - required properties are required", async (t) => {
t.false(json.validateSchema(testSchema, {}));
t.false(json.validateSchema(testSchema, { requiredKey: undefined }));
t.false(json.validateSchema(testSchema, { requiredKey: null }));
t.false(json.validateSchema(testSchema, { requiredKey: 0 }));
t.false(json.validateSchema(testSchema, { requiredKey: 123 }));
t.false(json.validateSchema(testSchema, { requiredKey: false }));
t.false(json.validateSchema(testSchema, { requiredKey: true }));
t.false(json.validateSchema(testSchema, { requiredKey: [] }));
t.false(json.validateSchema(testSchema, { requiredKey: {} }));
t.true(json.validateSchema(testSchema, { requiredKey: "" }));
t.true(json.validateSchema(testSchema, { requiredKey: "foo" }));
});

test("validateSchema - optional properties are optional", async (t) => {
// Optional fields may be absent
t.true(json.validateSchema(optionalSchema, {}));
t.true(json.validateSchema(optionalSchema, { optionalKey: undefined }));
t.true(json.validateSchema(optionalSchema, { optionalKey: null }));

// But, if present, should have the expected type
t.false(json.validateSchema(optionalSchema, { optionalKey: 0 }));
t.false(json.validateSchema(optionalSchema, { optionalKey: 123 }));
t.false(json.validateSchema(optionalSchema, { optionalKey: false }));
t.false(json.validateSchema(optionalSchema, { optionalKey: true }));
t.false(json.validateSchema(optionalSchema, { optionalKey: [] }));
t.false(json.validateSchema(optionalSchema, { optionalKey: {} }));
t.true(json.validateSchema(optionalSchema, { optionalKey: "" }));
t.true(json.validateSchema(optionalSchema, { optionalKey: "foo" }));
});
74 changes: 74 additions & 0 deletions src/json/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,77 @@ export function isStringOrUndefined(
): value is string | undefined {
return value === undefined || isString(value);
}

/**
* Represents a field of type `T` in a schema.
* Carries a validation function and flag indicating whether the field is required or not.
*/
export type Validator<T> = {
validate: (val: unknown) => val is T;
required: boolean;
};

/** Extracts `T` from `Validator<T>`. */
export type UnwrapValidator<V> =
V extends Validator<infer A>
? V["required"] extends true
? A
: A | undefined
: never;

/** A validator for string fields in schemas. */
export const string = {
validate: isString,
required: true,
} as const satisfies Validator<string>;

/** Transforms a validator to be optional. */
export function optional<T>(validator: Validator<T>) {
return {
validate: (val: unknown) => {
return val === undefined || val === null || validator.validate(val);
},
required: false,
} as const satisfies Validator<T | undefined | null>;
}

/** Represents an arbitrary object schema. */
export type Schema = Record<string, Validator<any>>;

/** Constructs an object type corresponding to a schema. */
export type FromSchema<S extends Schema> = {
[K in keyof S]: UnwrapValidator<S[K]>;
Comment thread
mbg marked this conversation as resolved.
Outdated
};

/**
* Validates `obj` against `schema`.
Comment thread
mbg marked this conversation as resolved.
Outdated
*
* @param schema The schema to validate against.
* @param obj The object to validate.
* @returns Asserts that `obj` is of the `schema`'s type if validation is successful.
*/
export function validateSchema<S extends Schema>(
schema: S,
obj: UnvalidatedObject<any>,
): obj is FromSchema<S> {
for (const [key, validator] of Object.entries(schema)) {
const hasKey = key in obj;

// If the property is required, but absent, fail.
if (validator.required && !hasKey) {
return false;
}

// If the property is required, but undefined or null, fail.
if (validator.required && (obj[key] === undefined || obj[key] === null)) {
return false;
}

// If the property is present, validate it.
if (hasKey && !validator.validate(obj[key])) {
return false;
}
}

return true;
}