From a08984c73c0cf1330952893545e6466542d8a246 Mon Sep 17 00:00:00 2001 From: Nicolas Morel Date: Fri, 16 Oct 2026 16:03:45 +0200 Subject: [PATCH] feat: accept undefined values in prepare --- API.md | 4 +++ lib/extend.js | 10 ++++++ lib/index.d.ts | 9 ++++- lib/schemas.js | 14 +++++--- lib/validator.js | 1 - test/extend.js | 94 ++++++++++++++++++++++++++++++++++++++++++++++++ test/index.ts | 21 +++++++++++ 7 files changed, 147 insertions(+), 6 deletions(-) diff --git a/API.md b/API.md index a3552cf4..15d8b628 100755 --- a/API.md +++ b/API.md @@ -3234,6 +3234,10 @@ Where: - `errors`: Validation error(s) generated by `$_createError()` or `helpers.error()`. If `errors` is defined, validation will abort regardless of `abortEarly`. Refer to the validation process above for further information. + + You can also pass an object where: + - `method`: The function described above. + - `undefined`: When `true`, `method` is also called when the input value is `undefined`, otherwise it is skipped. Defaults to `false`. Return nothing to leave the value `undefined` and let the next extensions prepare it, returning `{ value: undefined }` stops the chain. - `coerce`: A function with signature `function (value, helpers) {}` that coerces the input value where: - `value`: The input value. - `helpers`: [Validation helpers](#validation-helpers) diff --git a/lib/extend.js b/lib/extend.js index 723b2e45..3b084177 100755 --- a/lib/extend.js +++ b/lib/extend.js @@ -53,6 +53,16 @@ exports.type = function (from, options) { // Prepare + if (def.prepare) { + const { method, undefined: acceptsUndefined } = typeof def.prepare === 'function' ? { method: def.prepare } : def.prepare; + def.prepare = acceptsUndefined ? method : (value, helpers) => { + + if (value !== undefined) { + return method(value, helpers); + } + }; + } + def.prepare = internals.prepare(def.prepare, parent.prepare); // Coerce diff --git a/lib/index.d.ts b/lib/index.d.ts index 8658a40c..479274b6 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -2222,6 +2222,13 @@ declare namespace Joi { from?: string | string[]; } + type PrepareFunction = (value: any, helpers: CustomHelpers) => any; + + interface PrepareObject { + method: PrepareFunction; + undefined?: boolean; + } + interface ExtensionFlag { setter?: string; default?: any; @@ -2252,7 +2259,7 @@ declare namespace Joi { messages?: LanguageMessages | string; modifiers?: Record any>; overrides?: Record Schema>; - prepare?(value: any, helpers: CustomHelpers): any; + prepare?: PrepareFunction | PrepareObject; rebuild?(schema: ExtensionBoundSchema): void; rules?: Record>; terms?: Record; diff --git a/lib/schemas.js b/lib/schemas.js index 7bfb5b65..dbfde596 100755 --- a/lib/schemas.js +++ b/lib/schemas.js @@ -60,6 +60,9 @@ exports.preferences = Joi.object({ internals.nameRx = /^[a-zA-Z0-9]\w*$/; +internals.hook = Joi.function().maxArity(3); + + internals.rule = Joi.object({ alias: Joi.array().items(Joi.string().pattern(internals.nameRx)).single(), args: Joi.array().items( @@ -99,8 +102,8 @@ exports.extension = Joi.object({ base: Joi.object().schema() .when('type', { is: Joi.object().regex(), then: Joi.forbidden() }), coerce: [ - Joi.function().maxArity(3), - Joi.object({ method: Joi.function().maxArity(3).required(), from: Joi.array().items(Joi.string()).single() }) + internals.hook, + Joi.object({ method: internals.hook.required(), from: Joi.array().items(Joi.string()).single() }) ], flags: Joi.object().pattern(internals.nameRx, Joi.object({ setter: Joi.string(), @@ -112,7 +115,10 @@ exports.extension = Joi.object({ messages: [Joi.object(), Joi.string()], modifiers: Joi.object().pattern(internals.nameRx, Joi.function().minArity(1).maxArity(2)), overrides: Joi.object().pattern(internals.nameRx, Joi.function()), - prepare: Joi.function().maxArity(3), + prepare: [ + internals.hook, + Joi.object({ method: internals.hook.required(), undefined: Joi.boolean() }) + ], rebuild: Joi.function().arity(1), rules: Joi.object().pattern(internals.nameRx, internals.rule), jsonSchema: Joi.function(), @@ -129,7 +135,7 @@ exports.extension = Joi.object({ }) ]) })), - validate: Joi.function().maxArity(3) + validate: internals.hook }) .strict(); diff --git a/lib/validator.js b/lib/validator.js index 765e5c6e..966f870a 100755 --- a/lib/validator.js +++ b/lib/validator.js @@ -328,7 +328,6 @@ exports.validate = function (value, schema, state, prefs, overrides = {}) { const def = schema._definition; if (def.prepare && - value !== undefined && prefs.convert) { const prepared = def.prepare(value, helpers); diff --git a/test/extend.js b/test/extend.js index 68b7c6d6..0c6b0644 100755 --- a/test/extend.js +++ b/test/extend.js @@ -1548,6 +1548,100 @@ describe('extension', () => { expect(custom.number().validate(0)).to.equal({ value: undefined }); }); + it('extends any to prepare undefined values', () => { + + const source = { a: '1' }; + const custom = Joi.extend({ + type: /^.*$/, + prepare: { + undefined: true, + method(value, { schema }) { + + const key = schema.$_getFlag('source'); + if (value === undefined && + key !== undefined && + source[key] !== undefined) { + + return { value: source[key] }; + } + } + }, + rules: { + source: { + method(key) { + + return this.$_setFlag('source', key); + } + } + } + }); + + const schema = custom.object({ + a: custom.number().source('a').default(2), + b: custom.number().source('b').default(3), + c: custom.string().source('c').required() + }); + + expect(schema.validate({}).error).to.be.an.error('"c" is required'); + expect(schema.validate({ a: 4, c: 'x' })).to.equal({ value: { a: 4, b: 3, c: 'x' } }); + expect(schema.validate({}, { convert: false }).error).to.be.an.error('"c" is required'); + expect(custom.number().source('a').validate(undefined)).to.equal({ value: 1 }); + }); + + it('extends any with an explicit prepare object', () => { + + const custom = Joi.extend((joi) => ({ + type: 'number', + base: joi.number(), + prepare: { + method(value) { + + return { value: value.replace(',', '.') }; + } + } + })); + + expect(custom.number().validate('2,0')).to.equal({ value: 2.0 }); + expect(custom.number().validate(undefined)).to.equal({ value: undefined }); + }); + + it('chains prepare methods that accept undefined with those that don\'t', () => { + + const supply = (joi) => ({ + type: 'number', + base: joi.number(), + prepare: { + undefined: true, + method(value) { + + if (value === undefined) { + return { value: '1,5' }; + } + } + } + }); + + const comma = (joi) => ({ + type: 'number', + base: joi.number(), + prepare(value) { + + if (typeof value === 'string') { + return { value: value.replace(',', '.') }; + } + } + }); + + // Later extensions prepare first, comma is skipped on undefined and runs before supply + const supplyFirst = Joi.extend(supply, comma); + expect(supplyFirst.number().validate(undefined).error).to.be.an.error('"value" must be a number'); + expect(supplyFirst.number().validate('2,5')).to.equal({ value: 2.5 }); + + const commaFirst = Joi.extend(comma, supply); + expect(commaFirst.number().validate(undefined)).to.equal({ value: 1.5 }); + expect(commaFirst.number().validate('2,5')).to.equal({ value: 2.5 }); + }); + it('extends object to support string coerce', () => { const custom = Joi.extend({ diff --git a/test/index.ts b/test/index.ts index 15675d7d..8dbe87e5 100644 --- a/test/index.ts +++ b/test/index.ts @@ -1188,6 +1188,27 @@ description = schema.describe(); const Joi2 = Joi.extend({ type: 'test1', base: schema }); +Joi.extend((joi) => ({ + type: 'number', + base: joi.number(), + prepare(value, helpers) { + return { value }; + }, +})); + +Joi.extend((joi) => ({ + type: 'number', + base: joi.number(), + prepare: { + undefined: true, + method(value, helpers) { + return { value }; + }, + }, +})); + +expect.error(Joi.extend((joi) => ({ type: 'number', base: joi.number(), prepare: { undefined: true } }))); + const Joi3 = Joi.extend({ type: 'string', base: Joi.string(),