@mpen/valibot-extras 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,148 @@
1
+ # @mpen/valibot-extras
2
+
3
+ Extras and extensions for [Valibot](https://valibot.dev/).
4
+
5
+ ## Motivation
6
+
7
+ Valibot's built-in string validators have some surprising behaviors:
8
+
9
+ ### `v.string()` accepts ill-formed strings
10
+
11
+ `v.string()` happily accepts strings containing lone/unpaired UTF-16 surrogates:
12
+
13
+ ```ts
14
+ v.safeParse(v.string(), '\ud800').success // ✅ true — but this is not valid Unicode!
15
+ ```
16
+
17
+ These ill-formed strings can cause issues downstream (e.g. when serializing to JSON, sending over the network, or storing in a database).
18
+
19
+ ### `v.minLength` / `v.maxLength` count code units, not characters
20
+
21
+ Valibot's length validators use JavaScript's `.length` property, which counts UTF-16 code units — not visible characters. Characters outside the Basic Multilingual Plane (emoji, CJK ideographs, etc.) are represented as surrogate pairs and count as **2**:
22
+
23
+ ```ts
24
+ // '𠮷' is a single character, but 2 UTF-16 code units
25
+ v.safeParse(v.pipe(v.string(), v.minLength(2)), '𠮷').success // ✅ true — '𠮷'.length === 2
26
+
27
+ // '𠮷𠮷' is 2 characters, but 4 code units
28
+ v.safeParse(v.pipe(v.string(), v.maxLength(3)), '𠮷𠮷').success // ❌ false — despite being only 2 chars
29
+ ```
30
+
31
+ This is particularly problematic when enforcing limits that should match database column widths (e.g. MySQL's `VARCHAR(n)`, which counts characters, not bytes or code units).
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ bun add @mpen/valibot-extras
37
+ ```
38
+
39
+ ## API
40
+
41
+ ### `wellFormed(message?)`
42
+
43
+ A Valibot action that rejects strings containing lone/unpaired UTF-16 surrogates (uses [`String.prototype.isWellFormed()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/isWellFormed)):
44
+
45
+ ```ts
46
+ import * as v from 'valibot'
47
+ import * as vx from '@mpen/valibot-extras'
48
+
49
+ const schema = v.pipe(v.string(), vx.wellFormed())
50
+
51
+ v.safeParse(schema, 'hello 𠮷').success // ✅ true
52
+ v.safeParse(schema, '\ud800').success // ❌ false
53
+ ```
54
+
55
+ ### `unicodeString(message?)`
56
+
57
+ Shorthand for `v.pipe(v.string(), wellFormed())` — a string schema that only accepts well-formed Unicode:
58
+
59
+ ```ts
60
+ import * as vx from '@mpen/valibot-extras'
61
+
62
+ const schema = vx.unicodeString()
63
+
64
+ v.safeParse(schema, 'abc').success // ✅ true
65
+ v.safeParse(schema, '\ud800').success // ❌ false
66
+ ```
67
+
68
+ ### `minCharLength(requirement, message?)`
69
+
70
+ Like `v.minLength`, but counts Unicode code points (characters) instead of UTF-16 code units:
71
+
72
+ ```ts
73
+ import * as v from 'valibot'
74
+ import * as vx from '@mpen/valibot-extras'
75
+
76
+ const schema = v.pipe(v.string(), vx.minCharLength(3))
77
+
78
+ v.safeParse(schema, '𠮷𠮷𠮷').success // ✅ true — 3 characters
79
+ v.safeParse(schema, '𠮷𠮷').success // ❌ false — only 2 characters
80
+ ```
81
+
82
+ ### `maxCharLength(requirement, message?)`
83
+
84
+ Like `v.maxLength`, but counts Unicode code points instead of UTF-16 code units:
85
+
86
+ ```ts
87
+ import * as v from 'valibot'
88
+ import * as vx from '@mpen/valibot-extras'
89
+
90
+ const schema = v.pipe(v.string(), vx.maxCharLength(3))
91
+
92
+ v.safeParse(schema, '𠮷𠮷𠮷').success // ✅ true — 3 characters
93
+ v.safeParse(schema, '𠮷𠮷𠮷𠮷').success // ❌ false — 4 characters
94
+ ```
95
+
96
+ ### `charLength(requirement, message?)`
97
+
98
+ Like `v.length`, but counts Unicode code points instead of UTF-16 code units:
99
+
100
+ ```ts
101
+ import * as v from 'valibot'
102
+ import * as vx from '@mpen/valibot-extras'
103
+
104
+ const schema = v.pipe(v.string(), vx.charLength(3))
105
+
106
+ v.safeParse(schema, '𠮷𠮷𠮷').success // ✅ true — exactly 3 characters
107
+ v.safeParse(schema, '𠮷𠮷').success // ❌ false — only 2
108
+ v.safeParse(schema, '𠮷𠮷𠮷𠮷').success // ❌ false — 4
109
+ ```
110
+
111
+ ### `toJsonSchema(schema, config?)`
112
+
113
+ Converts a Valibot schema to JSON Schema, with support for custom JSON Schema annotations attached via [`attachJsonSchema`](#attachjsonschematarget-jsonschema).
114
+
115
+ This wraps `@valibot/to-json-schema` and adds `overrideSchema`/`overrideAction` hooks that look for schemas attached via the `jsonSchemaSymbol`. Attached schemas are merged into the converted output, preserving properties from the base conversion.
116
+
117
+ ```ts
118
+ import * as v from 'valibot'
119
+ import * as vx from '@mpen/valibot-extras'
120
+
121
+ const schema = v.pipe(v.string(), vx.minCharLength(5), vx.maxCharLength(10))
122
+ const jsonSchema = vx.toJsonSchema(schema)
123
+ // { type: 'string', minLength: 5, maxLength: 10, $schema: '...' }
124
+ ```
125
+
126
+ ### `attachJsonSchema(target, jsonSchema)`
127
+
128
+ Attaches a custom JSON Schema fragment to any Valibot schema or action via a non-enumerable Symbol property. This is used internally by `wellFormed`, `minCharLength`, etc. to make them compatible with `toJsonSchema`, but you can use it to annotate your own custom actions:
129
+
130
+ ```ts
131
+ import * as v from 'valibot'
132
+ import * as vx from '@mpen/valibot-extras'
133
+
134
+ const myAction = v.check((input: string) => input.startsWith('https://'), 'Must be HTTPS')
135
+ const annotated = vx.attachJsonSchema(myAction, { format: 'uri' })
136
+
137
+ const schema = v.pipe(v.string(), annotated)
138
+ const jsonSchema = vx.toJsonSchema(schema)
139
+ // { type: 'string', format: 'uri', $schema: '...' }
140
+ ```
141
+
142
+ ### `jsonSchemaSymbol`
143
+
144
+ The Symbol used by [`attachJsonSchema`](#attachjsonschematarget-jsonschema) to store the JSON Schema on a Valibot schema or action. Exposed for advanced use cases (e.g. reading back attached schemas).
145
+
146
+ ### JSON Schema Compatibility
147
+
148
+ All string validators in this package are compatible with `@valibot/to-json-schema` (and the included `toJsonSchema` wrapper). The `minCharLength` / `maxCharLength` / `charLength` actions produce the standard `minLength` / `maxLength` JSON Schema keywords.
@@ -0,0 +1,132 @@
1
+ import * as v from "valibot";
2
+ import { ConversionConfig, JsonSchema } from "@valibot/to-json-schema";
3
+
4
+ //#region src/strings.d.ts
5
+ /**
6
+ * Creates a Valibot action to validate that a string is well-formed Unicode.
7
+ *
8
+ * This checks that the string contains no lone/unpaired UTF-16 surrogate characters.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import * as v from 'valibot';
13
+ * import { wellFormed } from '@mpen/valibot-extras';
14
+ *
15
+ * const schema = v.pipe(v.string(), wellFormed());
16
+ * ```
17
+ *
18
+ * @param message - The error message.
19
+ * @returns The Valibot action.
20
+ */
21
+ declare function wellFormed(message?: string): v.CheckAction<string, string>;
22
+ /**
23
+ * Creates a Valibot schema to validate a well-formed Unicode string.
24
+ *
25
+ * This combines `v.string()` with the [`wellFormed`]{@link wellFormed} validation action.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * import { unicodeString } from '@mpen/valibot-extras';
30
+ *
31
+ * const schema = unicodeString('Invalid string');
32
+ * ```
33
+ *
34
+ * @param message - The error message.
35
+ * @returns The Valibot schema.
36
+ */
37
+ declare function unicodeString(message?: string): v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.CheckAction<string, string>]>;
38
+ /**
39
+ * Creates a Valibot action to validate the minimum character length of a string (counting Unicode code points).
40
+ *
41
+ * This validator counts UTF-16 surrogate pairs as a single character (matching MySQL's `VARCHAR` length behavior).
42
+ * See also [`maxCharLength`]{@link maxCharLength} and [`charLength`]{@link charLength}.
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * import * as v from 'valibot';
47
+ * import { minCharLength } from '@mpen/valibot-extras';
48
+ *
49
+ * const schema = v.pipe(v.string(), minCharLength(5));
50
+ * ```
51
+ *
52
+ * @param requirement - The minimum character length requirement.
53
+ * @param message - The error message.
54
+ * @returns The Valibot action.
55
+ */
56
+ declare function minCharLength<const TRequirement extends number>(requirement: TRequirement, message?: string): never;
57
+ /**
58
+ * Creates a Valibot action to validate the maximum character length of a string (counting Unicode code points).
59
+ *
60
+ * This validator counts UTF-16 surrogate pairs as a single character (matching MySQL's `VARCHAR` length behavior).
61
+ * See also [`minCharLength`]{@link minCharLength} and [`charLength`]{@link charLength}.
62
+ *
63
+ * @example
64
+ * ```ts
65
+ * import * as v from 'valibot';
66
+ * import { maxCharLength } from '@mpen/valibot-extras';
67
+ *
68
+ * const schema = v.pipe(v.string(), maxCharLength(10));
69
+ * ```
70
+ *
71
+ * @param requirement - The maximum character length requirement.
72
+ * @param message - The error message.
73
+ * @returns The Valibot action.
74
+ */
75
+ declare function maxCharLength<const TRequirement extends number>(requirement: TRequirement, message?: string): never;
76
+ /**
77
+ * Creates a Valibot action to validate the exact character length of a string (counting Unicode code points).
78
+ *
79
+ * This validator counts UTF-16 surrogate pairs as a single character.
80
+ * See also [`minCharLength`]{@link minCharLength} and [`maxCharLength`]{@link maxCharLength}.
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * import * as v from 'valibot';
85
+ * import { charLength } from '@mpen/valibot-extras';
86
+ *
87
+ * const schema = v.pipe(v.string(), charLength(5));
88
+ * ```
89
+ *
90
+ * @param requirement - The exact character length requirement.
91
+ * @param message - The error message.
92
+ * @returns The Valibot action.
93
+ */
94
+ declare function charLength<const TRequirement extends number>(requirement: TRequirement, message?: string): never;
95
+ //#endregion
96
+ //#region src/json-schema.d.ts
97
+ /**
98
+ * The symbol used to attach a custom JSON Schema to a Valibot schema or action.
99
+ */
100
+ declare const jsonSchemaSymbol: unique symbol;
101
+ /**
102
+ * Attaches a custom JSON Schema to a Valibot schema or action using a Symbol.
103
+ *
104
+ * @param target - The target Valibot schema or action.
105
+ * @param jsonSchema - The JSON Schema to attach.
106
+ * @returns The target object with the attached JSON Schema.
107
+ */
108
+ declare function attachJsonSchema<T extends object>(target: T, jsonSchema: JsonSchema): T;
109
+ /**
110
+ * Converts a Valibot schema to JSON Schema, with support for custom attached JSON Schemas.
111
+ *
112
+ * This wraps `@valibot/to-json-schema`'s `toJsonSchema` and implements `overrideSchema`
113
+ * and `overrideAction` to look for schemas attached via [`attachJsonSchema`]{@link attachJsonSchema}.
114
+ * Attached schemas are merged into the converted JSON Schema, preserving properties from
115
+ * the base conversion.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * import * as v from 'valibot';
120
+ * import { toJsonSchema, unicodeString } from '@mpen/valibot-extras';
121
+ *
122
+ * const schema = unicodeString();
123
+ * const jsonSchema = toJsonSchema(schema);
124
+ * ```
125
+ *
126
+ * @param schema - The Valibot schema to convert.
127
+ * @param config - Optional conversion configuration.
128
+ * @returns The generated JSON Schema.
129
+ */
130
+ declare function toJsonSchema(schema: v.GenericSchema, config?: ConversionConfig): JsonSchema;
131
+ //#endregion
132
+ export { attachJsonSchema, charLength, jsonSchemaSymbol, maxCharLength, minCharLength, toJsonSchema, unicodeString, wellFormed };
package/dist/index.js ADDED
@@ -0,0 +1,211 @@
1
+ import * as v from "valibot";
2
+ import { toJsonSchema as toJsonSchema$1 } from "@valibot/to-json-schema";
3
+ //#region src/json-schema.ts
4
+ /**
5
+ * The symbol used to attach a custom JSON Schema to a Valibot schema or action.
6
+ */
7
+ const jsonSchemaSymbol = Symbol("jsonSchema");
8
+ /**
9
+ * Attaches a custom JSON Schema to a Valibot schema or action using a Symbol.
10
+ *
11
+ * @param target - The target Valibot schema or action.
12
+ * @param jsonSchema - The JSON Schema to attach.
13
+ * @returns The target object with the attached JSON Schema.
14
+ */
15
+ function attachJsonSchema(target, jsonSchema) {
16
+ Object.defineProperty(target, jsonSchemaSymbol, {
17
+ value: jsonSchema,
18
+ configurable: true,
19
+ enumerable: false,
20
+ writable: true
21
+ });
22
+ return target;
23
+ }
24
+ /**
25
+ * Converts a Valibot schema to JSON Schema, with support for custom attached JSON Schemas.
26
+ *
27
+ * This wraps `@valibot/to-json-schema`'s `toJsonSchema` and implements `overrideSchema`
28
+ * and `overrideAction` to look for schemas attached via [`attachJsonSchema`]{@link attachJsonSchema}.
29
+ * Attached schemas are merged into the converted JSON Schema, preserving properties from
30
+ * the base conversion.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * import * as v from 'valibot';
35
+ * import { toJsonSchema, unicodeString } from '@mpen/valibot-extras';
36
+ *
37
+ * const schema = unicodeString();
38
+ * const jsonSchema = toJsonSchema(schema);
39
+ * ```
40
+ *
41
+ * @param schema - The Valibot schema to convert.
42
+ * @param config - Optional conversion configuration.
43
+ * @returns The generated JSON Schema.
44
+ */
45
+ function toJsonSchema(schema, config) {
46
+ return toJsonSchema$1(schema, {
47
+ ...config,
48
+ overrideSchema(context) {
49
+ const schemaOverride = context.valibotSchema[jsonSchemaSymbol];
50
+ if (schemaOverride !== void 0) return {
51
+ ...context.jsonSchema,
52
+ ...schemaOverride
53
+ };
54
+ return config?.overrideSchema?.(context);
55
+ },
56
+ overrideAction(context) {
57
+ const actionOverride = context.valibotAction[jsonSchemaSymbol];
58
+ if (actionOverride !== void 0) return {
59
+ ...context.jsonSchema,
60
+ ...actionOverride
61
+ };
62
+ return config?.overrideAction?.(context);
63
+ }
64
+ });
65
+ }
66
+ //#endregion
67
+ //#region src/strings.ts
68
+ /**
69
+ * Creates a Valibot action to validate that a string is well-formed Unicode.
70
+ *
71
+ * This checks that the string contains no lone/unpaired UTF-16 surrogate characters.
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * import * as v from 'valibot';
76
+ * import { wellFormed } from '@mpen/valibot-extras';
77
+ *
78
+ * const schema = v.pipe(v.string(), wellFormed());
79
+ * ```
80
+ *
81
+ * @param message - The error message.
82
+ * @returns The Valibot action.
83
+ */
84
+ function wellFormed(message = "String is not well-formed Unicode") {
85
+ return attachJsonSchema(v.check((input) => input.isWellFormed(), message), {});
86
+ }
87
+ /**
88
+ * Creates a Valibot schema to validate a well-formed Unicode string.
89
+ *
90
+ * This combines `v.string()` with the [`wellFormed`]{@link wellFormed} validation action.
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * import { unicodeString } from '@mpen/valibot-extras';
95
+ *
96
+ * const schema = unicodeString('Invalid string');
97
+ * ```
98
+ *
99
+ * @param message - The error message.
100
+ * @returns The Valibot schema.
101
+ */
102
+ function unicodeString(message) {
103
+ return attachJsonSchema(v.pipe(v.string(), wellFormed(message)), { type: "string" });
104
+ }
105
+ /**
106
+ * Creates a Valibot action to validate the minimum character length of a string (counting Unicode code points).
107
+ *
108
+ * This validator counts UTF-16 surrogate pairs as a single character (matching MySQL's `VARCHAR` length behavior).
109
+ * See also [`maxCharLength`]{@link maxCharLength} and [`charLength`]{@link charLength}.
110
+ *
111
+ * @example
112
+ * ```ts
113
+ * import * as v from 'valibot';
114
+ * import { minCharLength } from '@mpen/valibot-extras';
115
+ *
116
+ * const schema = v.pipe(v.string(), minCharLength(5));
117
+ * ```
118
+ *
119
+ * @param requirement - The minimum character length requirement.
120
+ * @param message - The error message.
121
+ * @returns The Valibot action.
122
+ */
123
+ function minCharLength(requirement, message) {
124
+ const action = v.rawCheck(({ dataset, addIssue }) => {
125
+ if (!dataset.typed || typeof dataset.value !== "string") return;
126
+ const length = [...dataset.value].length;
127
+ if (length < requirement) addIssue({
128
+ message,
129
+ expected: `>=${requirement}`,
130
+ received: `${length}`
131
+ });
132
+ });
133
+ return Object.assign(action, {
134
+ type: "min_length",
135
+ expects: `>=${requirement}`,
136
+ requirement,
137
+ message
138
+ });
139
+ }
140
+ /**
141
+ * Creates a Valibot action to validate the maximum character length of a string (counting Unicode code points).
142
+ *
143
+ * This validator counts UTF-16 surrogate pairs as a single character (matching MySQL's `VARCHAR` length behavior).
144
+ * See also [`minCharLength`]{@link minCharLength} and [`charLength`]{@link charLength}.
145
+ *
146
+ * @example
147
+ * ```ts
148
+ * import * as v from 'valibot';
149
+ * import { maxCharLength } from '@mpen/valibot-extras';
150
+ *
151
+ * const schema = v.pipe(v.string(), maxCharLength(10));
152
+ * ```
153
+ *
154
+ * @param requirement - The maximum character length requirement.
155
+ * @param message - The error message.
156
+ * @returns The Valibot action.
157
+ */
158
+ function maxCharLength(requirement, message) {
159
+ const action = v.rawCheck(({ dataset, addIssue }) => {
160
+ if (!dataset.typed || typeof dataset.value !== "string") return;
161
+ const length = [...dataset.value].length;
162
+ if (length > requirement) addIssue({
163
+ message,
164
+ expected: `<=${requirement}`,
165
+ received: `${length}`
166
+ });
167
+ });
168
+ return Object.assign(action, {
169
+ type: "max_length",
170
+ expects: `<=${requirement}`,
171
+ requirement,
172
+ message
173
+ });
174
+ }
175
+ /**
176
+ * Creates a Valibot action to validate the exact character length of a string (counting Unicode code points).
177
+ *
178
+ * This validator counts UTF-16 surrogate pairs as a single character.
179
+ * See also [`minCharLength`]{@link minCharLength} and [`maxCharLength`]{@link maxCharLength}.
180
+ *
181
+ * @example
182
+ * ```ts
183
+ * import * as v from 'valibot';
184
+ * import { charLength } from '@mpen/valibot-extras';
185
+ *
186
+ * const schema = v.pipe(v.string(), charLength(5));
187
+ * ```
188
+ *
189
+ * @param requirement - The exact character length requirement.
190
+ * @param message - The error message.
191
+ * @returns The Valibot action.
192
+ */
193
+ function charLength(requirement, message) {
194
+ const action = v.rawCheck(({ dataset, addIssue }) => {
195
+ if (!dataset.typed || typeof dataset.value !== "string") return;
196
+ const length = [...dataset.value].length;
197
+ if (length !== requirement) addIssue({
198
+ message,
199
+ expected: `${requirement}`,
200
+ received: `${length}`
201
+ });
202
+ });
203
+ return Object.assign(action, {
204
+ type: "length",
205
+ expects: `${requirement}`,
206
+ requirement,
207
+ message
208
+ });
209
+ }
210
+ //#endregion
211
+ export { attachJsonSchema, charLength, jsonSchemaSymbol, maxCharLength, minCharLength, toJsonSchema, unicodeString, wellFormed };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@mpen/valibot-extras",
3
+ "description": "Extras/extensions for Valibot.",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "files": [
7
+ "dist",
8
+ "README.md"
9
+ ],
10
+ "scripts": {
11
+ "build": "bun run --bun tsdown",
12
+ "dev": "bun run --bun tsdown --watch",
13
+ "test": "bun test src"
14
+ },
15
+ "peerDependencies": {
16
+ "valibot": "^1.4.1",
17
+ "@valibot/to-json-schema": "^1.7.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^18.11.18",
21
+ "bun-types": "^1.3.13",
22
+ "tsdown": "^0.21",
23
+ "typescript": "^6",
24
+ "valibot": "^1.4.1",
25
+ "@valibot/to-json-schema": "^1.7.0"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/mnpenner/npm-packages.git",
30
+ "directory": "packages/valibot-extras"
31
+ },
32
+ "exports": {
33
+ ".": "./dist/index.js",
34
+ "./package.json": "./package.json"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "types": "./dist/index.d.ts"
40
+ }