@atproto/lex-schema 0.0.12 → 0.0.13

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.
Files changed (199) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/core/schema.d.ts +4 -4
  3. package/dist/core/schema.d.ts.map +1 -1
  4. package/dist/core/schema.js +1 -1
  5. package/dist/core/schema.js.map +1 -1
  6. package/dist/core/validation-issue.js +3 -1
  7. package/dist/core/validation-issue.js.map +1 -1
  8. package/dist/core/validator.d.ts +10 -2
  9. package/dist/core/validator.d.ts.map +1 -1
  10. package/dist/core/validator.js +21 -3
  11. package/dist/core/validator.js.map +1 -1
  12. package/dist/helpers.d.ts +10 -11
  13. package/dist/helpers.d.ts.map +1 -1
  14. package/dist/helpers.js.map +1 -1
  15. package/dist/schema/array.d.ts +1 -0
  16. package/dist/schema/array.d.ts.map +1 -1
  17. package/dist/schema/array.js +2 -1
  18. package/dist/schema/array.js.map +1 -1
  19. package/dist/schema/blob.d.ts +4 -2
  20. package/dist/schema/blob.d.ts.map +1 -1
  21. package/dist/schema/blob.js +5 -2
  22. package/dist/schema/blob.js.map +1 -1
  23. package/dist/schema/boolean.d.ts +1 -0
  24. package/dist/schema/boolean.d.ts.map +1 -1
  25. package/dist/schema/boolean.js +2 -1
  26. package/dist/schema/boolean.js.map +1 -1
  27. package/dist/schema/bytes.d.ts +1 -0
  28. package/dist/schema/bytes.d.ts.map +1 -1
  29. package/dist/schema/bytes.js +2 -1
  30. package/dist/schema/bytes.js.map +1 -1
  31. package/dist/schema/cid.d.ts +1 -0
  32. package/dist/schema/cid.d.ts.map +1 -1
  33. package/dist/schema/cid.js +2 -1
  34. package/dist/schema/cid.js.map +1 -1
  35. package/dist/schema/custom.d.ts +1 -0
  36. package/dist/schema/custom.d.ts.map +1 -1
  37. package/dist/schema/custom.js +1 -0
  38. package/dist/schema/custom.js.map +1 -1
  39. package/dist/schema/dict.d.ts +1 -0
  40. package/dist/schema/dict.d.ts.map +1 -1
  41. package/dist/schema/dict.js +2 -1
  42. package/dist/schema/dict.js.map +1 -1
  43. package/dist/schema/discriminated-union.d.ts +1 -0
  44. package/dist/schema/discriminated-union.d.ts.map +1 -1
  45. package/dist/schema/discriminated-union.js +2 -1
  46. package/dist/schema/discriminated-union.js.map +1 -1
  47. package/dist/schema/enum.d.ts +1 -0
  48. package/dist/schema/enum.d.ts.map +1 -1
  49. package/dist/schema/enum.js +1 -0
  50. package/dist/schema/enum.js.map +1 -1
  51. package/dist/schema/integer.d.ts +1 -0
  52. package/dist/schema/integer.d.ts.map +1 -1
  53. package/dist/schema/integer.js +2 -1
  54. package/dist/schema/integer.js.map +1 -1
  55. package/dist/schema/intersection.d.ts +1 -0
  56. package/dist/schema/intersection.d.ts.map +1 -1
  57. package/dist/schema/intersection.js +1 -0
  58. package/dist/schema/intersection.js.map +1 -1
  59. package/dist/schema/lex-map.d.ts +37 -0
  60. package/dist/schema/lex-map.d.ts.map +1 -0
  61. package/dist/schema/lex-map.js +60 -0
  62. package/dist/schema/lex-map.js.map +1 -0
  63. package/dist/schema/lex-value.d.ts +35 -0
  64. package/dist/schema/lex-value.d.ts.map +1 -0
  65. package/dist/schema/lex-value.js +87 -0
  66. package/dist/schema/lex-value.js.map +1 -0
  67. package/dist/schema/literal.d.ts +1 -0
  68. package/dist/schema/literal.d.ts.map +1 -1
  69. package/dist/schema/literal.js +1 -0
  70. package/dist/schema/literal.js.map +1 -1
  71. package/dist/schema/never.d.ts +1 -0
  72. package/dist/schema/never.d.ts.map +1 -1
  73. package/dist/schema/never.js +2 -1
  74. package/dist/schema/never.js.map +1 -1
  75. package/dist/schema/null.d.ts +1 -0
  76. package/dist/schema/null.d.ts.map +1 -1
  77. package/dist/schema/null.js +2 -1
  78. package/dist/schema/null.js.map +1 -1
  79. package/dist/schema/nullable.d.ts +1 -0
  80. package/dist/schema/nullable.d.ts.map +1 -1
  81. package/dist/schema/nullable.js +1 -0
  82. package/dist/schema/nullable.js.map +1 -1
  83. package/dist/schema/object.d.ts +1 -0
  84. package/dist/schema/object.d.ts.map +1 -1
  85. package/dist/schema/object.js +2 -1
  86. package/dist/schema/object.js.map +1 -1
  87. package/dist/schema/optional.d.ts +1 -0
  88. package/dist/schema/optional.d.ts.map +1 -1
  89. package/dist/schema/optional.js +1 -0
  90. package/dist/schema/optional.js.map +1 -1
  91. package/dist/schema/params.d.ts +14 -10
  92. package/dist/schema/params.d.ts.map +1 -1
  93. package/dist/schema/params.js +84 -24
  94. package/dist/schema/params.js.map +1 -1
  95. package/dist/schema/payload.d.ts.map +1 -1
  96. package/dist/schema/payload.js +3 -3
  97. package/dist/schema/payload.js.map +1 -1
  98. package/dist/schema/record.d.ts +13 -17
  99. package/dist/schema/record.d.ts.map +1 -1
  100. package/dist/schema/record.js +1 -0
  101. package/dist/schema/record.js.map +1 -1
  102. package/dist/schema/ref.d.ts +1 -0
  103. package/dist/schema/ref.d.ts.map +1 -1
  104. package/dist/schema/ref.js +1 -0
  105. package/dist/schema/ref.js.map +1 -1
  106. package/dist/schema/regexp.d.ts +1 -0
  107. package/dist/schema/regexp.d.ts.map +1 -1
  108. package/dist/schema/regexp.js +2 -1
  109. package/dist/schema/regexp.js.map +1 -1
  110. package/dist/schema/string.d.ts +22 -6
  111. package/dist/schema/string.d.ts.map +1 -1
  112. package/dist/schema/string.js +16 -9
  113. package/dist/schema/string.js.map +1 -1
  114. package/dist/schema/token.d.ts +1 -0
  115. package/dist/schema/token.d.ts.map +1 -1
  116. package/dist/schema/token.js +2 -1
  117. package/dist/schema/token.js.map +1 -1
  118. package/dist/schema/typed-object.d.ts +11 -15
  119. package/dist/schema/typed-object.d.ts.map +1 -1
  120. package/dist/schema/typed-object.js +2 -1
  121. package/dist/schema/typed-object.js.map +1 -1
  122. package/dist/schema/typed-ref.d.ts +1 -0
  123. package/dist/schema/typed-ref.d.ts.map +1 -1
  124. package/dist/schema/typed-ref.js +1 -0
  125. package/dist/schema/typed-ref.js.map +1 -1
  126. package/dist/schema/typed-union.d.ts +1 -0
  127. package/dist/schema/typed-union.d.ts.map +1 -1
  128. package/dist/schema/typed-union.js +2 -1
  129. package/dist/schema/typed-union.js.map +1 -1
  130. package/dist/schema/union.d.ts +1 -0
  131. package/dist/schema/union.d.ts.map +1 -1
  132. package/dist/schema/union.js +1 -0
  133. package/dist/schema/union.js.map +1 -1
  134. package/dist/schema/unknown.d.ts +1 -0
  135. package/dist/schema/unknown.d.ts.map +1 -1
  136. package/dist/schema/unknown.js +1 -0
  137. package/dist/schema/unknown.js.map +1 -1
  138. package/dist/schema/with-default.d.ts +1 -0
  139. package/dist/schema/with-default.d.ts.map +1 -1
  140. package/dist/schema/with-default.js +1 -0
  141. package/dist/schema/with-default.js.map +1 -1
  142. package/dist/schema.d.ts +2 -1
  143. package/dist/schema.d.ts.map +1 -1
  144. package/dist/schema.js +2 -1
  145. package/dist/schema.js.map +1 -1
  146. package/dist/util/if-any.d.ts +2 -0
  147. package/dist/util/if-any.d.ts.map +1 -0
  148. package/dist/util/if-any.js +3 -0
  149. package/dist/util/if-any.js.map +1 -0
  150. package/package.json +2 -2
  151. package/src/core/schema.ts +9 -3
  152. package/src/core/validation-issue.ts +3 -2
  153. package/src/core/validator.ts +23 -3
  154. package/src/helpers.test.ts +1 -1
  155. package/src/helpers.ts +53 -19
  156. package/src/schema/array.ts +3 -1
  157. package/src/schema/blob.ts +4 -1
  158. package/src/schema/boolean.ts +3 -1
  159. package/src/schema/bytes.ts +3 -1
  160. package/src/schema/cid.ts +3 -1
  161. package/src/schema/custom.ts +2 -0
  162. package/src/schema/dict.ts +3 -1
  163. package/src/schema/discriminated-union.ts +3 -1
  164. package/src/schema/enum.ts +2 -0
  165. package/src/schema/integer.ts +3 -1
  166. package/src/schema/intersection.ts +2 -0
  167. package/src/schema/{unknown-object.test.ts → lex-map.test.ts} +9 -9
  168. package/src/schema/lex-map.ts +63 -0
  169. package/src/schema/lex-value.test.ts +81 -0
  170. package/src/schema/lex-value.ts +86 -0
  171. package/src/schema/literal.ts +2 -0
  172. package/src/schema/never.ts +3 -1
  173. package/src/schema/null.ts +3 -1
  174. package/src/schema/nullable.ts +2 -0
  175. package/src/schema/object.ts +3 -1
  176. package/src/schema/optional.ts +2 -0
  177. package/src/schema/params.test.ts +82 -43
  178. package/src/schema/params.ts +133 -39
  179. package/src/schema/payload.test.ts +2 -2
  180. package/src/schema/payload.ts +3 -4
  181. package/src/schema/record.ts +19 -8
  182. package/src/schema/ref.ts +2 -0
  183. package/src/schema/regexp.ts +3 -1
  184. package/src/schema/string.test.ts +99 -2
  185. package/src/schema/string.ts +58 -15
  186. package/src/schema/token.ts +3 -1
  187. package/src/schema/typed-object.ts +19 -8
  188. package/src/schema/typed-ref.ts +2 -0
  189. package/src/schema/typed-union.ts +3 -1
  190. package/src/schema/union.ts +2 -0
  191. package/src/schema/unknown.ts +2 -0
  192. package/src/schema/with-default.ts +2 -0
  193. package/src/schema.ts +2 -1
  194. package/src/util/if-any.ts +3 -0
  195. package/dist/schema/unknown-object.d.ts +0 -42
  196. package/dist/schema/unknown-object.d.ts.map +0 -1
  197. package/dist/schema/unknown-object.js +0 -50
  198. package/dist/schema/unknown-object.js.map +0 -1
  199. package/src/schema/unknown-object.ts +0 -53
@@ -1,9 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { unknownObject } from './unknown-object.js'
2
+ import { lexMap } from './lex-map.js'
3
3
 
4
- describe('UnknownObjectSchema', () => {
4
+ describe(lexMap, () => {
5
5
  describe('basic validation', () => {
6
- const schema = unknownObject()
6
+ const schema = lexMap()
7
7
 
8
8
  it('accepts empty plain objects', () => {
9
9
  const result = schema.safeParse({})
@@ -106,7 +106,7 @@ describe('UnknownObjectSchema', () => {
106
106
  })
107
107
 
108
108
  describe('rejects non-plain-objects', () => {
109
- const schema = unknownObject()
109
+ const schema = lexMap()
110
110
 
111
111
  it('rejects strings', () => {
112
112
  const result = schema.safeParse('not an object')
@@ -208,7 +208,7 @@ describe('UnknownObjectSchema', () => {
208
208
  })
209
209
 
210
210
  describe('rejects invalid value types', () => {
211
- const schema = unknownObject()
211
+ const schema = lexMap()
212
212
 
213
213
  it('rejects objects with floating point numbers', () => {
214
214
  const result = schema.safeParse({ value: 3.14 })
@@ -288,7 +288,7 @@ describe('UnknownObjectSchema', () => {
288
288
  })
289
289
 
290
290
  describe('rejects invalid nested values', () => {
291
- const schema = unknownObject()
291
+ const schema = lexMap()
292
292
 
293
293
  it('rejects deeply nested invalid values', () => {
294
294
  const result = schema.safeParse({
@@ -335,7 +335,7 @@ describe('UnknownObjectSchema', () => {
335
335
  })
336
336
 
337
337
  describe('edge cases', () => {
338
- const schema = unknownObject()
338
+ const schema = lexMap()
339
339
 
340
340
  it('accepts objects with numeric string keys', () => {
341
341
  const obj = { '0': 'zero', '1': 'one', '2': 'two' }
@@ -500,7 +500,7 @@ describe('UnknownObjectSchema', () => {
500
500
  })
501
501
 
502
502
  describe('large objects', () => {
503
- const schema = unknownObject()
503
+ const schema = lexMap()
504
504
 
505
505
  it('accepts objects with many keys', () => {
506
506
  const obj: Record<string, number> = {}
@@ -538,7 +538,7 @@ describe('UnknownObjectSchema', () => {
538
538
  })
539
539
 
540
540
  describe('preservation of input', () => {
541
- const schema = unknownObject()
541
+ const schema = lexMap()
542
542
 
543
543
  it('preserves the original object reference', () => {
544
544
  const input = { key: 'value', count: 42 }
@@ -0,0 +1,63 @@
1
+ import { LexMap, isPlainObject } from '@atproto/lex-data'
2
+ import { Schema, ValidationContext } from '../core.js'
3
+ import { memoizedOptions } from '../util/memoize.js'
4
+ import { lexValue } from './lex-value.js'
5
+
6
+ const propertyValueSchema = /*#__PURE__*/ lexValue()
7
+
8
+ export type { LexMap }
9
+
10
+ /**
11
+ * AT Protocol lexicon schema definitions with "type": "unknown" are represented
12
+ * as plain objects with string keys and values that are valid AT Protocol data
13
+ * types (string, integer, boolean, null, bytes, cid, array, or object). This
14
+ * type alias corresponds to the expected structure of such "unknown" schema
15
+ * values.
16
+ */
17
+ export class LexMapSchema extends Schema<LexMap> {
18
+ readonly type = 'lexMap' as const
19
+
20
+ validateInContext(input: unknown, ctx: ValidationContext) {
21
+ if (!isPlainObject(input)) {
22
+ return ctx.issueUnexpectedType(input, 'object')
23
+ }
24
+
25
+ for (const key of Object.keys(input)) {
26
+ // @NOTE We use a lexValue() schema here to recursively validate all
27
+ // nested values, which ensures that the error reporting includes the
28
+ // correct path and type information for any invalid nested values. This
29
+ // allows for more informative error descriptions than a simple "isLexMap"
30
+ // check.
31
+ const r = ctx.validateChild(input, key, propertyValueSchema) // recursively validate all properties
32
+ if (!r.success) return r
33
+ }
34
+
35
+ return ctx.success(input)
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Creates a schema that accepts any plain object with string keys and values
41
+ * that are valid AT Protocol data types (string, integer, boolean, null, bytes,
42
+ * cid, array, or object).
43
+ *
44
+ * @see {@link LexMap} from `@atproto/lex-data` for the type definition of valid AT Protocol data types
45
+ * @returns A new {@link LexMapSchema} instance
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * // Accept any object shape
50
+ * const schema = l.lexMap()
51
+ *
52
+ * schema.validate({ any: 'props' }) // success
53
+ * schema.validate([1, 2, 3]) // fails - only plain objects are accepted
54
+ * schema.validate({ foo: new Date() }) // fails - Date is not a valid LexValue
55
+ * schema.validate({ foo: 1.2 }) // fails - 1.2 is not a valid LexValue (not an integer)
56
+ * ```
57
+ */
58
+ export const lexMap = /*#__PURE__*/ memoizedOptions(function () {
59
+ return new LexMapSchema()
60
+ })
61
+
62
+ /** @deprecated Use {@link lexMap} instead */
63
+ export const unknownObject = lexMap
@@ -0,0 +1,81 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { parseCid } from '@atproto/lex-data'
3
+ import { lexValue } from './lex-value.js'
4
+
5
+ const schema = lexValue()
6
+
7
+ describe(lexValue, () => {
8
+ describe('valid values', () => {
9
+ for (const { note, value } of [
10
+ { note: 'string', value: 'hello' },
11
+ { note: 'boolean true', value: true },
12
+ { note: 'boolean false', value: false },
13
+ { note: 'null', value: null },
14
+ { note: 'integer', value: 42 },
15
+ { note: 'negative integer', value: -1 },
16
+ { note: 'zero', value: 0 },
17
+ { note: 'Uint8Array', value: new Uint8Array([1, 2, 3]) },
18
+ {
19
+ note: 'Cid',
20
+ value: parseCid(
21
+ 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
22
+ ),
23
+ },
24
+ { note: 'empty plain object', value: {} },
25
+ {
26
+ note: 'object with Lex values',
27
+ value: {
28
+ a: 123,
29
+ b: 'blah',
30
+ c: true,
31
+ d: null,
32
+ e: new Uint8Array([1, 2, 3]),
33
+ f: { nested: 'value' },
34
+ g: [1, 2, 3],
35
+ },
36
+ },
37
+ { note: 'empty array', value: [] },
38
+ {
39
+ note: 'array with Lex values',
40
+ value: [
41
+ 123,
42
+ 'blah',
43
+ true,
44
+ null,
45
+ new Uint8Array([1, 2, 3]),
46
+ { nested: 'value' },
47
+ [1, 2, 3],
48
+ ],
49
+ },
50
+ ]) {
51
+ test(note, () => {
52
+ const result = schema.safeParse(value)
53
+ expect(result.success).toBe(true)
54
+ })
55
+ }
56
+ })
57
+
58
+ describe('invalid values', () => {
59
+ for (const { note, value } of [
60
+ { note: 'float', value: 42.5 },
61
+ { note: 'undefined', value: undefined },
62
+ { note: 'function', value: () => {} },
63
+ { note: 'Date object', value: new Date() },
64
+ { note: 'Map object', value: new Map() },
65
+ { note: 'Set object', value: new Set() },
66
+ { note: 'class instance', value: new (class A {})() },
67
+ { note: 'object with function value', value: { a: 123, b: () => {} } },
68
+ {
69
+ note: 'object with undefined value',
70
+ value: { a: 123, b: undefined },
71
+ },
72
+ { note: 'array with function', value: [123, 'blah', () => {}] },
73
+ { note: 'array with undefined', value: [123, 'blah', undefined] },
74
+ ]) {
75
+ test(note, () => {
76
+ const result = schema.safeParse(value)
77
+ expect(result.success).toBe(false)
78
+ })
79
+ }
80
+ })
81
+ })
@@ -0,0 +1,86 @@
1
+ import { LexValue, isLexScalar, isPlainObject } from '@atproto/lex-data'
2
+ import { Schema, ValidationContext } from '../core.js'
3
+ import { memoizedOptions } from '../util/memoize.js'
4
+
5
+ export type { LexValue }
6
+
7
+ const EXPECTED_TYPES = Object.freeze([
8
+ // Scalar types
9
+ 'null',
10
+ 'boolean',
11
+ 'integer',
12
+ 'string',
13
+ 'cid',
14
+ 'bytes',
15
+ // Recursive types
16
+ 'array',
17
+ 'object',
18
+ ] as const)
19
+
20
+ /**
21
+ * AT Protocol lexicon values are any valid AT Protocol data types: string,
22
+ * integer, boolean, null, bytes, cid, array, or object.
23
+ */
24
+ export class LexValueSchema extends Schema<LexValue> {
25
+ readonly type = 'lexValue' as const
26
+
27
+ validateInContext(input: unknown, ctx: ValidationContext) {
28
+ // @NOTE We are *not* using "isLexValue" here to allow for more specific
29
+ // error messages about the path and type of the invalid value. The
30
+ // "isLexValue" check is effectively performed by the recursive validation
31
+ // of child properties below.
32
+
33
+ // @NOTE There are two limitations to the fact that we are not using
34
+ // "isLexValue" here:
35
+ // 1. We cannot detect circular references in objects or arrays, which would
36
+ // cause infinite recursion. However, circular references are not valid
37
+ // AT Protocol data types, so this is not a concern for valid input. This
38
+ // could easily be addressed in the "validateChild" method by keeping
39
+ // track of "parent" objects.
40
+ // 2. We are limited in the recursion depth we can validate due to potential
41
+ // recursion depth limits in JavaScript. However, this is also not a
42
+ // concern for most valid input, as extremely deep nesting is unlikely in
43
+ // typical use cases.
44
+ if (isPlainObject(input)) {
45
+ for (const key of Object.keys(input)) {
46
+ const r = ctx.validateChild(input, key, this) // recursively validate all properties
47
+ if (!r.success) return r
48
+ }
49
+ } else if (Array.isArray(input)) {
50
+ for (let i = 0; i < input.length; i++) {
51
+ const r = ctx.validateChild(input, i, this) // recursively validate all array items
52
+ if (!r.success) return r
53
+ }
54
+ } else if (!isLexScalar(input)) {
55
+ return ctx.issueInvalidType(input, EXPECTED_TYPES)
56
+ }
57
+
58
+ return ctx.success(input)
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Creates a schema that accepts any valid AT Protocol data type: string,
64
+ * integer, boolean, null, bytes, cid, array, or plain object. Arrays and
65
+ * objects are recursively validated to ensure all nested values are also valid
66
+ * AT Protocol data types.
67
+ *
68
+ * @see {@link LexValue} from `@atproto/lex-data` for the type definition of valid AT Protocol data types
69
+ * @returns A new {@link LexValueSchema} instance
70
+ *
71
+ * @example
72
+ * ```ts
73
+ * const schema = l.lexValue()
74
+ *
75
+ * schema.validate('hello') // success
76
+ * schema.validate(42) // success
77
+ * schema.validate(null) // success
78
+ * schema.validate([1, 'two', null]) // success
79
+ * schema.validate({ any: 'props' }) // success
80
+ * schema.validate(new Date()) // fails - Date is not a valid LexValue
81
+ * schema.validate({ foo: 1.2 }) // fails - 1.2 is not a valid LexValue (not an integer)
82
+ * ```
83
+ */
84
+ export const lexValue = /*#__PURE__*/ memoizedOptions(function () {
85
+ return new LexValueSchema()
86
+ })
@@ -18,6 +18,8 @@ import { Schema, ValidationContext } from '../core.js'
18
18
  export class LiteralSchema<
19
19
  const TValue extends null | string | number | boolean,
20
20
  > extends Schema<TValue> {
21
+ readonly type = 'literal' as const
22
+
21
23
  constructor(readonly value: TValue) {
22
24
  super()
23
25
  }
@@ -14,8 +14,10 @@ import { memoizedOptions } from '../util/memoize.js'
14
14
  * ```
15
15
  */
16
16
  export class NeverSchema extends Schema<never> {
17
+ readonly type = 'never' as const
18
+
17
19
  validateInContext(input: unknown, ctx: ValidationContext) {
18
- return ctx.issueInvalidType(input, 'never')
20
+ return ctx.issueUnexpectedType(input, 'never')
19
21
  }
20
22
  }
21
23
 
@@ -15,9 +15,11 @@ import { memoizedOptions } from '../util/memoize.js'
15
15
  * ```
16
16
  */
17
17
  export class NullSchema extends Schema<null> {
18
+ readonly type = 'null' as const
19
+
18
20
  validateInContext(input: unknown, ctx: ValidationContext) {
19
21
  if (input !== null) {
20
- return ctx.issueInvalidType(input, 'null')
22
+ return ctx.issueUnexpectedType(input, 'null')
21
23
  }
22
24
 
23
25
  return ctx.success(null)
@@ -26,6 +26,8 @@ export class NullableSchema<const TValidator extends Validator> extends Schema<
26
26
  InferInput<TValidator> | null,
27
27
  InferOutput<TValidator> | null
28
28
  > {
29
+ readonly type = 'nullable' as const
30
+
29
31
  constructor(readonly validator: TValidator) {
30
32
  super()
31
33
  }
@@ -43,6 +43,8 @@ export class ObjectSchema<
43
43
  [K in keyof TShape]: InferOutput<TShape[K]>
44
44
  }>
45
45
  > {
46
+ readonly type = 'object' as const
47
+
46
48
  constructor(readonly shape: TShape) {
47
49
  super()
48
50
  }
@@ -55,7 +57,7 @@ export class ObjectSchema<
55
57
 
56
58
  validateInContext(input: unknown, ctx: ValidationContext) {
57
59
  if (!isPlainObject(input)) {
58
- return ctx.issueInvalidType(input, 'object')
60
+ return ctx.issueUnexpectedType(input, 'object')
59
61
  }
60
62
 
61
63
  // Lazily copy value
@@ -31,6 +31,8 @@ export class OptionalSchema<TValidator extends Validator> extends Schema<
31
31
  ? InferOutput<TValidator>
32
32
  : InferOutput<TValidator> | undefined
33
33
  > {
34
+ readonly type = 'optional' as const
35
+
34
36
  constructor(readonly validator: TValidator) {
35
37
  super()
36
38
  }
@@ -1,7 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { array } from './array.js'
3
3
  import { boolean } from './boolean.js'
4
+ import { enumSchema } from './enum.js'
4
5
  import { integer } from './integer.js'
6
+ import { literal } from './literal.js'
5
7
  import { optional } from './optional.js'
6
8
  import { paramSchema, params, paramsSchema } from './params.js'
7
9
  import { string } from './string.js'
@@ -260,6 +262,42 @@ describe('ParamsSchema', () => {
260
262
  })
261
263
  })
262
264
 
265
+ describe('coercion', () => {
266
+ it('throws for invalid enum values', () => {
267
+ const schema = params({
268
+ status: enumSchema(['active', 'inactive']),
269
+ })
270
+ expect(() => schema.fromURLSearchParams('status=unknown')).toThrow(
271
+ 'Expected one of "active" or "inactive"',
272
+ )
273
+ })
274
+
275
+ it('throws for invalid const values', () => {
276
+ const schema = params({
277
+ version: literal(42),
278
+ })
279
+ expect(() => schema.fromURLSearchParams('version=99')).toThrow(
280
+ 'Expected 42',
281
+ )
282
+ })
283
+
284
+ it('handles negative integer enum values', () => {
285
+ const schema = params({
286
+ offset: enumSchema([-10, 0, 10]),
287
+ })
288
+ const result = schema.fromURLSearchParams('offset=-10')
289
+ expect(result).toEqual({ offset: -10 })
290
+ })
291
+
292
+ it('handles boolean const false', () => {
293
+ const schema = params({
294
+ disabled: literal(false),
295
+ })
296
+ const result = schema.fromURLSearchParams('disabled=false')
297
+ expect(result).toEqual({ disabled: false })
298
+ })
299
+ })
300
+
263
301
  describe('fromURLSearchParams', () => {
264
302
  const schema = params({
265
303
  name: string(),
@@ -271,70 +309,58 @@ describe('ParamsSchema', () => {
271
309
  })
272
310
 
273
311
  it('parses string parameters', () => {
274
- const urlParams = new URLSearchParams('name=Alice')
275
- const result = schema.fromURLSearchParams(urlParams)
312
+ const result = schema.fromURLSearchParams('name=Alice')
276
313
  expect(result).toEqual({ name: 'Alice' })
277
314
  })
278
315
 
279
316
  it('parses and coerces boolean true', () => {
280
- const urlParams = new URLSearchParams('name=Alice&active=true')
281
- const result = schema.fromURLSearchParams(urlParams)
317
+ const result = schema.fromURLSearchParams('name=Alice&active=true')
282
318
  expect(result).toEqual({ name: 'Alice', active: true })
283
319
  })
284
320
 
285
321
  it('parses and coerces boolean false', () => {
286
- const urlParams = new URLSearchParams('name=Alice&active=false')
287
- const result = schema.fromURLSearchParams(urlParams)
322
+ const result = schema.fromURLSearchParams('name=Alice&active=false')
288
323
  expect(result).toEqual({ name: 'Alice', active: false })
289
324
  })
290
325
 
291
326
  it('parses and coerces integer values', () => {
292
- const urlParams = new URLSearchParams('name=Alice&age=30')
293
- const result = schema.fromURLSearchParams(urlParams)
327
+ const result = schema.fromURLSearchParams('name=Alice&age=30')
294
328
  expect(result).toEqual({ name: 'Alice', age: 30 })
295
329
  })
296
330
 
297
331
  it('parses and coerces negative integers', () => {
298
- const urlParams = new URLSearchParams('name=Alice&age=-5')
299
- const result = schema.fromURLSearchParams(urlParams)
332
+ const result = schema.fromURLSearchParams('name=Alice&age=-5')
300
333
  expect(result).toEqual({ name: 'Alice', age: -5 })
301
334
  })
302
335
 
303
336
  it('does not coerce non-integer numbers', () => {
304
- const urlParams = new URLSearchParams('name=Alice&extra=3.14')
305
- const result = schema.fromURLSearchParams(urlParams)
337
+ const result = schema.fromURLSearchParams('name=Alice&extra=3.14')
306
338
  expect(result).toEqual({ name: 'Alice', extra: '3.14' })
307
339
  })
308
340
 
309
341
  it('keeps string values for string schema even if they look like numbers', () => {
310
- const urlParams = new URLSearchParams('name=123')
311
- const result = schema.fromURLSearchParams(urlParams)
342
+ const result = schema.fromURLSearchParams('name=123')
312
343
  expect(result).toEqual({ name: '123' })
313
344
  })
314
345
 
315
346
  it('parses multiple values as array', () => {
316
- const urlParams = new URLSearchParams('name=Alice&tags=one&tags=two')
317
- const result = schema.fromURLSearchParams(urlParams)
347
+ const result = schema.fromURLSearchParams('name=Alice&tags=one&tags=two')
318
348
  expect(result).toEqual({ name: 'Alice', tags: ['one', 'two'] })
319
349
  })
320
350
 
321
- it('coerces array values correctly', () => {
322
- const urlParams = new URLSearchParams('name=Alice&num=1&num=2&num=3')
323
- const result = schema.fromURLSearchParams(urlParams)
324
- expect(result).toEqual({ name: 'Alice', num: [1, 2, 3] })
325
- })
351
+ it('does not coerce numeric values of unknown params', () => {
352
+ expect(
353
+ schema.fromURLSearchParams('name=Alice&num=1&num=2&num=3&foo=3'),
354
+ ).toEqual({ name: 'Alice', num: ['1', '2', '3'], foo: '3' })
326
355
 
327
- it('handles mixed types in arrays', () => {
328
- const urlParams = new URLSearchParams(
329
- 'name=Alice&val=true&val=123&val=text',
330
- )
331
- const result = schema.fromURLSearchParams(urlParams)
332
- expect(result).toEqual({ name: 'Alice', val: [true, 123, 'text'] })
356
+ expect(
357
+ schema.fromURLSearchParams('name=Alice&val=true&val=123&val=text'),
358
+ ).toEqual({ name: 'Alice', val: ['true', '123', 'text'] })
333
359
  })
334
360
 
335
361
  it('handles empty URLSearchParams', () => {
336
- const urlParams = new URLSearchParams()
337
- expect(() => schema.fromURLSearchParams(urlParams)).toThrow()
362
+ expect(() => schema.fromURLSearchParams(new URLSearchParams())).toThrow()
363
+ expect(() => schema.fromURLSearchParams('')).toThrow()
338
364
  })
339
365
 
340
366
  it('handles multiple parameters', () => {
@@ -393,14 +419,19 @@ describe('ParamsSchema', () => {
393
419
  ['name', 'Alice'],
394
420
  ['bools', 'notabool'],
395
421
  ]),
396
- ).toThrow('Expected boolean value type at $.bools[0] (got string)')
422
+ ).toThrow('Expected boolean value type at $.bools (got string)')
397
423
 
398
424
  expect(() =>
399
- schema.fromURLSearchParams([
400
- ['name', 'Alice'],
401
- ['bools', '2'],
402
- ]),
403
- ).toThrow('Expected boolean value type at $.bools[0] (got integer)')
425
+ schema.fromURLSearchParams(
426
+ [
427
+ ['name', 'Alice'],
428
+ ['bools', '2'],
429
+ ],
430
+ {
431
+ path: ['foo', 'bar'],
432
+ },
433
+ ),
434
+ ).toThrow('Expected boolean value type at $.foo.bar.bools (got string)')
404
435
  })
405
436
  })
406
437
 
@@ -461,15 +492,23 @@ describe('ParamsSchema', () => {
461
492
  expect(result.toString()).toBe('name=Alice')
462
493
  })
463
494
 
495
+ it('rejects arrays with multiple types', () => {
496
+ expect(() => {
497
+ schema.toURLSearchParams({
498
+ name: 'Alice',
499
+ // @ts-expect-error
500
+ values: [1, true, 'text'],
501
+ })
502
+ }).toThrow()
503
+ })
504
+
464
505
  it('handles arrays with multiple types', () => {
465
506
  const result = schema.toURLSearchParams({
466
507
  name: 'Alice',
467
508
  // @ts-expect-error
468
- values: [1, true, 'text'],
509
+ values: ['foo', 'bar'],
469
510
  })
470
- expect(result.toString()).toBe(
471
- 'name=Alice&values=1&values=true&values=text',
472
- )
511
+ expect(result.toString()).toBe('name=Alice&values=foo&values=bar')
473
512
  })
474
513
 
475
514
  it('handles undefined input', () => {
@@ -711,9 +750,9 @@ describe('paramSchema', () => {
711
750
  expect(result.success).toBe(true)
712
751
  })
713
752
 
714
- it('validates arrays with mixed scalar types', () => {
753
+ it('rejects arrays with mixed scalar types', () => {
715
754
  const result = paramSchema.safeParse([true, 42, 'text'])
716
- expect(result.success).toBe(true)
755
+ expect(result.success).toBe(false)
717
756
  })
718
757
 
719
758
  it('validates arrays with negative integers', () => {
@@ -884,11 +923,11 @@ describe('paramsSchema', () => {
884
923
  expect(result.success).toBe(true)
885
924
  })
886
925
 
887
- it('validates object with arrays of mixed scalar types', () => {
926
+ it('rejects object with arrays of mixed scalar types', () => {
888
927
  const result = paramsSchema.safeParse({
889
928
  values: [true, 42, 'text'],
890
929
  })
891
- expect(result.success).toBe(true)
930
+ expect(result.success).toBe(false)
892
931
  })
893
932
 
894
933
  it('validates object with numeric string keys', () => {