diff --git a/src/json/index.test.ts b/src/json/index.test.ts index 825bbc0e7..d37a86c37 100644 --- a/src/json/index.test.ts +++ b/src/json/index.test.ts @@ -44,3 +44,26 @@ test("validateSchema - optional properties are optional", async (t) => { t.true(json.validateSchema(optionalSchema, { optionalKey: "" })); t.true(json.validateSchema(optionalSchema, { optionalKey: "foo" })); }); + +const undefinableSchema = { + optionalKey: json.undefinable(json.string), +}; + +test("validateSchema - undefinable properties are optional but reject null", async (t) => { + // Optional fields may be absent or explicitly undefined + t.true(json.validateSchema(undefinableSchema, {})); + t.true(json.validateSchema(undefinableSchema, { optionalKey: undefined })); + + // But, unlike `optional`, `null` is rejected + t.false(json.validateSchema(undefinableSchema, { optionalKey: null })); + + // And, if present, should have the expected type + t.false(json.validateSchema(undefinableSchema, { optionalKey: 0 })); + t.false(json.validateSchema(undefinableSchema, { optionalKey: 123 })); + t.false(json.validateSchema(undefinableSchema, { optionalKey: false })); + t.false(json.validateSchema(undefinableSchema, { optionalKey: true })); + t.false(json.validateSchema(undefinableSchema, { optionalKey: [] })); + t.false(json.validateSchema(undefinableSchema, { optionalKey: {} })); + t.true(json.validateSchema(undefinableSchema, { optionalKey: "" })); + t.true(json.validateSchema(undefinableSchema, { optionalKey: "foo" })); +}); diff --git a/src/json/index.ts b/src/json/index.ts index 8a1b60a17..4a3f65947 100644 --- a/src/json/index.ts +++ b/src/json/index.ts @@ -30,6 +30,11 @@ export function isString(value: unknown): value is string { return typeof value === "string"; } +/** Asserts that `value` is a number. */ +export function isNumber(value: unknown): value is number { + return typeof value === "number"; +} + /** Asserts that `value` is either a string or undefined. */ export function isStringOrUndefined( value: unknown, @@ -55,7 +60,22 @@ export const string = { required: true, } as const satisfies Validator; -/** Transforms a validator to be optional. */ +/** A validator for number fields in schemas. */ +export const number = { + validate: isNumber, + required: true, +} as const satisfies Validator; + +/** A validator for object fields in schemas. */ +export const object = { + validate: isObject, + required: true, +} as const satisfies Validator>; + +/** + * Transforms a validator to be optional, accepting `undefined` or `null` for an + * absent value. + */ export function optional(validator: Validator) { return { validate: (val: unknown) => { @@ -65,6 +85,19 @@ export function optional(validator: Validator) { } as const satisfies Validator; } +/** + * Transforms a validator to be optional, accepting `undefined` for an absent + * value but, unlike `optional`, rejecting `null`. + */ +export function undefinable(validator: Validator) { + return { + validate: (val: unknown): val is T | undefined => { + return val === undefined || validator.validate(val); + }, + required: false, + } as const satisfies Validator; +} + /** Represents an arbitrary object schema. */ export type Schema = Record>;