From e86c29f3432833dcb99c092cb1cd95b5c9c7a044 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Tue, 8 Sep 2026 15:33:57 +0000 Subject: [PATCH] feat: read the `format` keyword as part of the type A `format` was reported as a hint next to the constraints of the string: configuration.strictFormat should be a string (should match format "date", should be < "2016-02-06"). It names the string, so it now reads as part of the type: configuration.strictFormat should be a date string (should be < "2016-02-06"). The name is turned into words, so a `date-time`, a `snake_case` and a `camelCase` format all read as words. A format is any string though, not only a name - one that is not a name, such as the pattern `[0-9]*`, is still reported as a hint, and so is a negated format, which no type can state. Taken from #68, without the part of it moving `absolutePath` from a keyword to a format, which is breaking and was left open for discussion there. Co-Authored-By: Ivan Kopeykin <10380560+vankop@users.noreply.github.com> Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013wqLmVHkAGBsWQgXknEQCK --- .changeset/humanize-format.md | 5 +++++ declarations/util/humanize.d.ts | 2 ++ src/util/hints.js | 32 ++++++++++++++++++++++----- src/util/humanize.js | 29 ++++++++++++++++++++++++ test/__snapshots__/index.test.js.snap | 4 ++-- test/hints.test.js | 12 +++++++++- test/humanize.test.js | 23 +++++++++++++++++++ 7 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 .changeset/humanize-format.md create mode 100644 declarations/util/humanize.d.ts create mode 100644 src/util/humanize.js create mode 100644 test/humanize.test.js diff --git a/.changeset/humanize-format.md b/.changeset/humanize-format.md new file mode 100644 index 0000000..d86edd7 --- /dev/null +++ b/.changeset/humanize-format.md @@ -0,0 +1,5 @@ +--- +"schema-utils": patch +--- + +read the `format` keyword as part of the type, i.e. `should be a date string` instead of `should be a string (should match format "date")` diff --git a/declarations/util/humanize.d.ts b/declarations/util/humanize.d.ts new file mode 100644 index 0000000..b323843 --- /dev/null +++ b/declarations/util/humanize.d.ts @@ -0,0 +1,2 @@ +declare function _exports(str: string): string; +export = _exports; diff --git a/src/util/hints.js b/src/util/hints.js index ff2bb38..b89f526 100644 --- a/src/util/hints.js +++ b/src/util/hints.js @@ -1,4 +1,10 @@ const Range = require("./Range"); +const humanize = require("./humanize"); + +const STRING_TYPE_REGEXP = /string$/; +// A format that is a name, so it can be read as words. A format is any string, +// including a pattern like `[0-9]*`, and such a one is left to a hint +const FORMAT_NAME_REGEXP = /^[A-Za-z][A-Za-z\d]*(?:[-_][A-Za-z\d]+)*$/; /** @typedef {import("../validate").Schema} Schema */ @@ -50,6 +56,7 @@ module.exports.numberHints = function numberHints(schema, logic) { module.exports.stringHints = function stringHints(schema, logic) { const hints = []; let type = "string"; + let formatName = ""; const currentSchema = { ...schema }; if (!logic) { @@ -99,11 +106,17 @@ module.exports.stringHints = function stringHints(schema, logic) { } if (currentSchema.format) { - hints.push( - `should${logic ? "" : " not"} match format ${JSON.stringify( - currentSchema.format, - )}`, - ); + if (logic && FORMAT_NAME_REGEXP.test(currentSchema.format)) { + // The format names the string, `should be a date string` reads better than + // `should be a string (should match format "date")` + formatName = humanize(currentSchema.format); + } else { + hints.push( + `should${logic ? "" : " not"} match format ${JSON.stringify( + currentSchema.format, + )}`, + ); + } } if (currentSchema.formatMinimum) { @@ -122,5 +135,12 @@ module.exports.stringHints = function stringHints(schema, logic) { ); } - return [type, ...hints]; + // Every type here ends with `string`, the format names the string itself, so it + // goes next to that word - `non-empty email string`, not `email non-empty string` + return [ + formatName + ? type.replace(STRING_TYPE_REGEXP, `${formatName} string`) + : type, + ...hints, + ]; }; diff --git a/src/util/humanize.js b/src/util/humanize.js new file mode 100644 index 0000000..ffe2dd4 --- /dev/null +++ b/src/util/humanize.js @@ -0,0 +1,29 @@ +const CAMEL_CASE_REGEXP = /([^A-Z])([A-Z])/g; +const LEADING_UNDERSCORE_REGEXP = /^_/; + +/** + * Turns the name of a format into words, so `dash-case`, `snake_case`, + * `camelCase` and `PascalCase` all read as `dash case`, `snake case`, and so on. + * @param {string} str provided string + * @returns {string} the string as human readable words + */ +module.exports = function humanize(str) { + if (str.length < 2) { + return str; + } + + if (str.includes("-")) { + return str.split("-").join(" ").toLowerCase(); + } + + // A leading underscore is not a word boundary, `_12Integers` is `12 integers` + const withoutLeadingUnderscore = str.replace(LEADING_UNDERSCORE_REGEXP, ""); + + if (withoutLeadingUnderscore.includes("_")) { + return withoutLeadingUnderscore.split("_").join(" ").toLowerCase(); + } + + return withoutLeadingUnderscore + .replace(CAMEL_CASE_REGEXP, "$1 $2") + .toLowerCase(); +}; diff --git a/test/__snapshots__/index.test.js.snap b/test/__snapshots__/index.test.js.snap index 5c684d2..2bd8d2c 100644 --- a/test/__snapshots__/index.test.js.snap +++ b/test/__snapshots__/index.test.js.snap @@ -733,7 +733,7 @@ exports[`validation should fail validation for format, formatMaximum and formatE exports[`validation should fail validation for format, formatMaximum and formatExclusiveMaximum 1`] = ` "Invalid configuration object. Object has been initialized using a configuration object that does not match the API schema. - - configuration.strictFormat should be a string (should match format "date", should be < "2016-02-06")." + - configuration.strictFormat should be a date string (should be < "2016-02-06")." `; exports[`validation should fail validation for format, formatMinimum and formatExclusiveMinimum #2 1`] = ` @@ -743,7 +743,7 @@ exports[`validation should fail validation for format, formatMinimum and formatE exports[`validation should fail validation for format, formatMinimum and formatExclusiveMinimum 1`] = ` "Invalid configuration object. Object has been initialized using a configuration object that does not match the API schema. - - configuration.strictFormat2 should be a string (should match format "date", should be > "2016-02-06")." + - configuration.strictFormat2 should be a date string (should be > "2016-02-06")." `; exports[`validation should fail validation for formatExclusiveMaximum #1 1`] = ` diff --git a/test/hints.test.js b/test/hints.test.js index e51973e..41f3b71 100644 --- a/test/hints.test.js +++ b/test/hints.test.js @@ -30,13 +30,23 @@ const testCases = [ ['should match pattern "phone"'], ['should not match pattern "phone"'], ], + [ + { format: "date-time" }, + ["date time string"], + ['should not match format "date-time"'], + ], + [ + { format: "email", minLength: 1 }, + ["non-empty email string"], + ['should not match format "email"'], + ], [ { format: "date", formatMaximum: "01.01.2022", formatExclusiveMaximum: "01.01.2022", }, - ['should match format "date"', 'should be < "01.01.2022"'], + ["date string", 'should be < "01.01.2022"'], ['should not match format "date"', 'should be >= "01.01.2022"'], ], ]; diff --git a/test/humanize.test.js b/test/humanize.test.js new file mode 100644 index 0000000..dedd7ff --- /dev/null +++ b/test/humanize.test.js @@ -0,0 +1,23 @@ +const humanize = require("../src/util/humanize"); + +const words = [ + ["dash-case", "dash case"], + ["_snake_case", "snake case"], + ["snake-case", "snake case"], + ["PascalCase", "pascal case"], + ["_12Integers", "12 integers"], + ["awesomeStringFormat13", "awesome string format13"], + ["camelCase", "camel case"], + ["date-time", "date time"], + ["email", "email"], + ["a", "a"], + ["", ""], +]; + +describe("humanize", () => { + for (const [provided, expected] of words) { + it(JSON.stringify(provided), () => { + expect(humanize(provided)).toBe(expected); + }); + } +});