@atproto/lex-schema 0.0.12 → 0.0.14

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 (210) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/core/schema.d.ts +27 -36
  3. package/dist/core/schema.d.ts.map +1 -1
  4. package/dist/core/schema.js +68 -54
  5. package/dist/core/schema.js.map +1 -1
  6. package/dist/core/string-format.d.ts +1 -14
  7. package/dist/core/string-format.d.ts.map +1 -1
  8. package/dist/core/string-format.js +12 -9
  9. package/dist/core/string-format.js.map +1 -1
  10. package/dist/core/validation-error.d.ts +5 -5
  11. package/dist/core/validation-error.d.ts.map +1 -1
  12. package/dist/core/validation-error.js +8 -8
  13. package/dist/core/validation-error.js.map +1 -1
  14. package/dist/core/validation-issue.js +3 -1
  15. package/dist/core/validation-issue.js.map +1 -1
  16. package/dist/core/validator.d.ts +16 -8
  17. package/dist/core/validator.d.ts.map +1 -1
  18. package/dist/core/validator.js +24 -6
  19. package/dist/core/validator.js.map +1 -1
  20. package/dist/helpers.d.ts +10 -11
  21. package/dist/helpers.d.ts.map +1 -1
  22. package/dist/helpers.js.map +1 -1
  23. package/dist/schema/array.d.ts +1 -0
  24. package/dist/schema/array.d.ts.map +1 -1
  25. package/dist/schema/array.js +2 -1
  26. package/dist/schema/array.js.map +1 -1
  27. package/dist/schema/blob.d.ts +4 -2
  28. package/dist/schema/blob.d.ts.map +1 -1
  29. package/dist/schema/blob.js +5 -2
  30. package/dist/schema/blob.js.map +1 -1
  31. package/dist/schema/boolean.d.ts +1 -0
  32. package/dist/schema/boolean.d.ts.map +1 -1
  33. package/dist/schema/boolean.js +2 -1
  34. package/dist/schema/boolean.js.map +1 -1
  35. package/dist/schema/bytes.d.ts +1 -0
  36. package/dist/schema/bytes.d.ts.map +1 -1
  37. package/dist/schema/bytes.js +2 -1
  38. package/dist/schema/bytes.js.map +1 -1
  39. package/dist/schema/cid.d.ts +1 -0
  40. package/dist/schema/cid.d.ts.map +1 -1
  41. package/dist/schema/cid.js +2 -1
  42. package/dist/schema/cid.js.map +1 -1
  43. package/dist/schema/custom.d.ts +1 -0
  44. package/dist/schema/custom.d.ts.map +1 -1
  45. package/dist/schema/custom.js +1 -0
  46. package/dist/schema/custom.js.map +1 -1
  47. package/dist/schema/dict.d.ts +1 -0
  48. package/dist/schema/dict.d.ts.map +1 -1
  49. package/dist/schema/dict.js +2 -1
  50. package/dist/schema/dict.js.map +1 -1
  51. package/dist/schema/discriminated-union.d.ts +1 -0
  52. package/dist/schema/discriminated-union.d.ts.map +1 -1
  53. package/dist/schema/discriminated-union.js +2 -1
  54. package/dist/schema/discriminated-union.js.map +1 -1
  55. package/dist/schema/enum.d.ts +1 -0
  56. package/dist/schema/enum.d.ts.map +1 -1
  57. package/dist/schema/enum.js +1 -0
  58. package/dist/schema/enum.js.map +1 -1
  59. package/dist/schema/integer.d.ts +1 -0
  60. package/dist/schema/integer.d.ts.map +1 -1
  61. package/dist/schema/integer.js +2 -1
  62. package/dist/schema/integer.js.map +1 -1
  63. package/dist/schema/intersection.d.ts +1 -0
  64. package/dist/schema/intersection.d.ts.map +1 -1
  65. package/dist/schema/intersection.js +1 -0
  66. package/dist/schema/intersection.js.map +1 -1
  67. package/dist/schema/lex-map.d.ts +37 -0
  68. package/dist/schema/lex-map.d.ts.map +1 -0
  69. package/dist/schema/lex-map.js +60 -0
  70. package/dist/schema/lex-map.js.map +1 -0
  71. package/dist/schema/lex-value.d.ts +35 -0
  72. package/dist/schema/lex-value.d.ts.map +1 -0
  73. package/dist/schema/lex-value.js +87 -0
  74. package/dist/schema/lex-value.js.map +1 -0
  75. package/dist/schema/literal.d.ts +1 -0
  76. package/dist/schema/literal.d.ts.map +1 -1
  77. package/dist/schema/literal.js +1 -0
  78. package/dist/schema/literal.js.map +1 -1
  79. package/dist/schema/never.d.ts +1 -0
  80. package/dist/schema/never.d.ts.map +1 -1
  81. package/dist/schema/never.js +2 -1
  82. package/dist/schema/never.js.map +1 -1
  83. package/dist/schema/null.d.ts +1 -0
  84. package/dist/schema/null.d.ts.map +1 -1
  85. package/dist/schema/null.js +2 -1
  86. package/dist/schema/null.js.map +1 -1
  87. package/dist/schema/nullable.d.ts +1 -0
  88. package/dist/schema/nullable.d.ts.map +1 -1
  89. package/dist/schema/nullable.js +1 -0
  90. package/dist/schema/nullable.js.map +1 -1
  91. package/dist/schema/object.d.ts +1 -0
  92. package/dist/schema/object.d.ts.map +1 -1
  93. package/dist/schema/object.js +2 -1
  94. package/dist/schema/object.js.map +1 -1
  95. package/dist/schema/optional.d.ts +1 -0
  96. package/dist/schema/optional.d.ts.map +1 -1
  97. package/dist/schema/optional.js +1 -0
  98. package/dist/schema/optional.js.map +1 -1
  99. package/dist/schema/params.d.ts +14 -10
  100. package/dist/schema/params.d.ts.map +1 -1
  101. package/dist/schema/params.js +87 -24
  102. package/dist/schema/params.js.map +1 -1
  103. package/dist/schema/payload.d.ts.map +1 -1
  104. package/dist/schema/payload.js +3 -3
  105. package/dist/schema/payload.js.map +1 -1
  106. package/dist/schema/record.d.ts +21 -19
  107. package/dist/schema/record.d.ts.map +1 -1
  108. package/dist/schema/record.js +22 -12
  109. package/dist/schema/record.js.map +1 -1
  110. package/dist/schema/ref.d.ts +1 -0
  111. package/dist/schema/ref.d.ts.map +1 -1
  112. package/dist/schema/ref.js +1 -0
  113. package/dist/schema/ref.js.map +1 -1
  114. package/dist/schema/regexp.d.ts +1 -0
  115. package/dist/schema/regexp.d.ts.map +1 -1
  116. package/dist/schema/regexp.js +2 -1
  117. package/dist/schema/regexp.js.map +1 -1
  118. package/dist/schema/string.d.ts +22 -6
  119. package/dist/schema/string.d.ts.map +1 -1
  120. package/dist/schema/string.js +16 -9
  121. package/dist/schema/string.js.map +1 -1
  122. package/dist/schema/token.d.ts +1 -0
  123. package/dist/schema/token.d.ts.map +1 -1
  124. package/dist/schema/token.js +2 -1
  125. package/dist/schema/token.js.map +1 -1
  126. package/dist/schema/typed-object.d.ts +20 -16
  127. package/dist/schema/typed-object.d.ts.map +1 -1
  128. package/dist/schema/typed-object.js +23 -13
  129. package/dist/schema/typed-object.js.map +1 -1
  130. package/dist/schema/typed-ref.d.ts +1 -0
  131. package/dist/schema/typed-ref.d.ts.map +1 -1
  132. package/dist/schema/typed-ref.js +1 -0
  133. package/dist/schema/typed-ref.js.map +1 -1
  134. package/dist/schema/typed-union.d.ts +1 -0
  135. package/dist/schema/typed-union.d.ts.map +1 -1
  136. package/dist/schema/typed-union.js +2 -1
  137. package/dist/schema/typed-union.js.map +1 -1
  138. package/dist/schema/union.d.ts +1 -0
  139. package/dist/schema/union.d.ts.map +1 -1
  140. package/dist/schema/union.js +2 -1
  141. package/dist/schema/union.js.map +1 -1
  142. package/dist/schema/unknown.d.ts +1 -0
  143. package/dist/schema/unknown.d.ts.map +1 -1
  144. package/dist/schema/unknown.js +1 -0
  145. package/dist/schema/unknown.js.map +1 -1
  146. package/dist/schema/with-default.d.ts +1 -0
  147. package/dist/schema/with-default.d.ts.map +1 -1
  148. package/dist/schema/with-default.js +1 -0
  149. package/dist/schema/with-default.js.map +1 -1
  150. package/dist/schema.d.ts +2 -1
  151. package/dist/schema.d.ts.map +1 -1
  152. package/dist/schema.js +2 -1
  153. package/dist/schema.js.map +1 -1
  154. package/dist/util/if-any.d.ts +2 -0
  155. package/dist/util/if-any.d.ts.map +1 -0
  156. package/dist/util/if-any.js +3 -0
  157. package/dist/util/if-any.js.map +1 -0
  158. package/package.json +3 -3
  159. package/src/core/schema.ts +76 -62
  160. package/src/core/string-format.ts +14 -17
  161. package/src/core/validation-error.ts +10 -10
  162. package/src/core/validation-issue.ts +3 -2
  163. package/src/core/validator.ts +32 -12
  164. package/src/helpers.test.ts +1 -1
  165. package/src/helpers.ts +53 -19
  166. package/src/schema/array.ts +3 -1
  167. package/src/schema/blob.ts +4 -1
  168. package/src/schema/boolean.ts +3 -1
  169. package/src/schema/bytes.ts +3 -1
  170. package/src/schema/cid.ts +3 -1
  171. package/src/schema/custom.ts +2 -0
  172. package/src/schema/dict.ts +3 -1
  173. package/src/schema/discriminated-union.ts +3 -1
  174. package/src/schema/enum.ts +2 -0
  175. package/src/schema/integer.ts +3 -1
  176. package/src/schema/intersection.ts +2 -0
  177. package/src/schema/{unknown-object.test.ts → lex-map.test.ts} +9 -9
  178. package/src/schema/lex-map.ts +63 -0
  179. package/src/schema/lex-value.test.ts +81 -0
  180. package/src/schema/lex-value.ts +86 -0
  181. package/src/schema/literal.ts +2 -0
  182. package/src/schema/never.ts +3 -1
  183. package/src/schema/null.ts +3 -1
  184. package/src/schema/nullable.ts +2 -0
  185. package/src/schema/object.ts +3 -1
  186. package/src/schema/optional.ts +2 -0
  187. package/src/schema/params.test.ts +98 -43
  188. package/src/schema/params.ts +136 -39
  189. package/src/schema/payload.test.ts +2 -2
  190. package/src/schema/payload.ts +3 -4
  191. package/src/schema/record.ts +38 -22
  192. package/src/schema/ref.ts +2 -0
  193. package/src/schema/regexp.ts +3 -1
  194. package/src/schema/string.test.ts +99 -2
  195. package/src/schema/string.ts +58 -15
  196. package/src/schema/token.ts +3 -1
  197. package/src/schema/typed-object.test.ts +38 -0
  198. package/src/schema/typed-object.ts +40 -24
  199. package/src/schema/typed-ref.ts +2 -0
  200. package/src/schema/typed-union.ts +3 -1
  201. package/src/schema/union.ts +4 -2
  202. package/src/schema/unknown.ts +2 -0
  203. package/src/schema/with-default.ts +2 -0
  204. package/src/schema.ts +2 -1
  205. package/src/util/if-any.ts +3 -0
  206. package/dist/schema/unknown-object.d.ts +0 -42
  207. package/dist/schema/unknown-object.d.ts.map +0 -1
  208. package/dist/schema/unknown-object.js +0 -50
  209. package/dist/schema/unknown-object.js.map +0 -1
  210. package/src/schema/unknown-object.ts +0 -53
@@ -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,35 @@ 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)')
435
+ })
436
+
437
+ it('ignores empty string values', () => {
438
+ const result = schema.fromURLSearchParams([
439
+ ['name', 'Alice'],
440
+ ['extra', ''],
441
+ ])
442
+ expect(result).toEqual({ name: 'Alice' })
443
+ })
444
+
445
+ it('ignores empty string values for known parameters', () => {
446
+ const result = schema.fromURLSearchParams([
447
+ ['name', 'Alice'],
448
+ ['age', ''],
449
+ ])
450
+ expect(result).toEqual({ name: 'Alice' })
404
451
  })
405
452
  })
406
453
 
@@ -461,15 +508,23 @@ describe('ParamsSchema', () => {
461
508
  expect(result.toString()).toBe('name=Alice')
462
509
  })
463
510
 
511
+ it('rejects arrays with multiple types', () => {
512
+ expect(() => {
513
+ schema.toURLSearchParams({
514
+ name: 'Alice',
515
+ // @ts-expect-error
516
+ values: [1, true, 'text'],
517
+ })
518
+ }).toThrow()
519
+ })
520
+
464
521
  it('handles arrays with multiple types', () => {
465
522
  const result = schema.toURLSearchParams({
466
523
  name: 'Alice',
467
524
  // @ts-expect-error
468
- values: [1, true, 'text'],
525
+ values: ['foo', 'bar'],
469
526
  })
470
- expect(result.toString()).toBe(
471
- 'name=Alice&values=1&values=true&values=text',
472
- )
527
+ expect(result.toString()).toBe('name=Alice&values=foo&values=bar')
473
528
  })
474
529
 
475
530
  it('handles undefined input', () => {
@@ -711,9 +766,9 @@ describe('paramSchema', () => {
711
766
  expect(result.success).toBe(true)
712
767
  })
713
768
 
714
- it('validates arrays with mixed scalar types', () => {
769
+ it('rejects arrays with mixed scalar types', () => {
715
770
  const result = paramSchema.safeParse([true, 42, 'text'])
716
- expect(result.success).toBe(true)
771
+ expect(result.success).toBe(false)
717
772
  })
718
773
 
719
774
  it('validates arrays with negative integers', () => {
@@ -884,11 +939,11 @@ describe('paramsSchema', () => {
884
939
  expect(result.success).toBe(true)
885
940
  })
886
941
 
887
- it('validates object with arrays of mixed scalar types', () => {
942
+ it('rejects object with arrays of mixed scalar types', () => {
888
943
  const result = paramsSchema.safeParse({
889
944
  values: [true, 42, 'text'],
890
945
  })
891
- expect(result.success).toBe(true)
946
+ expect(result.success).toBe(false)
892
947
  })
893
948
 
894
949
  it('validates object with numeric string keys', () => {
@@ -3,6 +3,11 @@ import {
3
3
  Infer,
4
4
  InferInput,
5
5
  InferOutput,
6
+ Issue,
7
+ IssueInvalidType,
8
+ IssueInvalidValue,
9
+ LexValidationError,
10
+ ParseOptions,
6
11
  Schema,
7
12
  ValidationContext,
8
13
  Validator,
@@ -13,7 +18,9 @@ import { memoizedOptions } from '../util/memoize.js'
13
18
  import { ArraySchema, array } from './array.js'
14
19
  import { BooleanSchema, boolean } from './boolean.js'
15
20
  import { dict } from './dict.js'
21
+ import { EnumSchema } from './enum.js'
16
22
  import { IntegerSchema, integer } from './integer.js'
23
+ import { LiteralSchema } from './literal.js'
17
24
  import { OptionalSchema, optional } from './optional.js'
18
25
  import { StringSchema, string } from './string.js'
19
26
  import { union } from './union.js'
@@ -33,7 +40,12 @@ export type Param = Infer<typeof paramSchema>
33
40
  /**
34
41
  * Schema for validating individual parameter values.
35
42
  */
36
- export const paramSchema = union([paramScalarSchema, array(paramScalarSchema)])
43
+ export const paramSchema = union([
44
+ paramScalarSchema,
45
+ array(boolean()),
46
+ array(integer()),
47
+ array(string()),
48
+ ])
37
49
 
38
50
  /**
39
51
  * Type for a params object with string keys and optional param values.
@@ -45,14 +57,34 @@ export type Params = Infer<typeof paramsSchema>
45
57
  */
46
58
  export const paramsSchema = dict(string(), optional(paramSchema))
47
59
 
48
- // @NOTE In order to properly coerce URLSearchParams, we need to distinguish
49
- // between scalar and array validators, requiring to be able to detect which
50
- // schema types are being used, restricting the allowed param validators here.
51
- type ParamScalarValidator = StringSchema | BooleanSchema | IntegerSchema
52
- type ParamValueValidator =
60
+ export type ParamScalarValidator =
61
+ // @NOTE In order to properly coerce URLSearchParams, we need to distinguish
62
+ // between scalar and array validators, requiring to be able to detect which
63
+ // schema types are being used, restricting the allowed param validators here.
64
+ | LiteralSchema<string>
65
+ | LiteralSchema<number>
66
+ | LiteralSchema<boolean>
67
+ | EnumSchema<string>
68
+ | EnumSchema<number>
69
+ // | EnumSchema<boolean> // Boolean lexicon definitions don't allow "enum"
70
+ | StringSchema<any>
71
+ | BooleanSchema
72
+ | IntegerSchema
73
+
74
+ type AsArrayParamSchema<TSchema extends Validator> =
75
+ // This allows to "distribute" any union of scalar validators into a union of
76
+ // arrays of those validators, instead of an array of union. If TSchema is
77
+ // BooleanSchema | IntegerSchema, we want the result to be
78
+ // ArraySchema<BooleanSchema> | ArraySchema<IntegerSchema>, not
79
+ // ArraySchema<BooleanSchema | IntegerSchema>, since the latter would allow
80
+ // arrays with mixed types (e.g. [true, 42]), which we don't want.
81
+ TSchema extends any ? ArraySchema<TSchema> : never
82
+
83
+ export type ParamValueValidator =
53
84
  | ParamScalarValidator
54
- | ArraySchema<ParamScalarValidator>
55
- type ParamValidator =
85
+ | AsArrayParamSchema<ParamScalarValidator>
86
+
87
+ export type ParamValidator =
56
88
  | ParamValueValidator
57
89
  | OptionalSchema<ParamValueValidator>
58
90
  | OptionalSchema<WithDefaultSchema<ParamValueValidator>>
@@ -63,7 +95,7 @@ type ParamValidator =
63
95
  *
64
96
  * Maps parameter names to their validators (must be Param or undefined).
65
97
  */
66
- export type ParamsSchemaShape = {
98
+ export type ParamsShape = {
67
99
  [x: string]: ParamValidator
68
100
  }
69
101
 
@@ -87,7 +119,7 @@ export type ParamsSchemaShape = {
87
119
  * ```
88
120
  */
89
121
  export class ParamsSchema<
90
- const TShape extends ParamsSchemaShape = ParamsSchemaShape,
122
+ const TShape extends ParamsShape = ParamsShape,
91
123
  > extends Schema<
92
124
  WithOptionalProperties<{
93
125
  [K in keyof TShape]: InferInput<TShape[K]>
@@ -96,6 +128,8 @@ export class ParamsSchema<
96
128
  [K in keyof TShape]: InferOutput<TShape[K]>
97
129
  }>
98
130
  > {
131
+ readonly type = 'params' as const
132
+
99
133
  constructor(readonly shape: TShape) {
100
134
  super()
101
135
  }
@@ -108,7 +142,7 @@ export class ParamsSchema<
108
142
 
109
143
  validateInContext(input: unknown, ctx: ValidationContext) {
110
144
  if (!isPlainObject(input)) {
111
- return ctx.issueInvalidType(input, 'object')
145
+ return ctx.issueUnexpectedType(input, 'object')
112
146
  }
113
147
 
114
148
  // Lazily copy value
@@ -163,42 +197,41 @@ export class ParamsSchema<
163
197
  return ctx.success(copy ?? input)
164
198
  }
165
199
 
166
- fromURLSearchParams(iterable: Iterable<[string, string]>): InferOutput<this> {
167
- const params: Record<string, Param> = {}
200
+ fromURLSearchParams(
201
+ input: string | Iterable<[string, string]>,
202
+ options?: ParseOptions,
203
+ ): InferOutput<this> {
204
+ const params: Record<string, unknown> = {}
168
205
 
169
- // Compatibility with URLSearchParams not being iterable in some environments
206
+ const iterable =
207
+ typeof input === 'string' ? new URLSearchParams(input) : input
170
208
  const entries =
171
209
  iterable instanceof URLSearchParams ? iterable.entries() : iterable
172
210
 
173
- for (const [key, value] of entries) {
174
- const validator = unwrapValidator(this.shapeValidators.get(key))
175
- const expectsArray = validator instanceof ArraySchema
211
+ for (const [name, value] of entries) {
212
+ // Ignore empty strings
213
+ if (!value) continue
214
+
215
+ const validator = this.shapeValidators.get(name)
216
+ const innerValidator = validator ? unwrapSchema(validator) : undefined
217
+ const expectsArray = innerValidator instanceof ArraySchema
176
218
  const scalarValidator = expectsArray
177
- ? unwrapValidator(validator.validator)
178
- : validator
179
-
180
- const coerced: ParamScalar =
181
- scalarValidator instanceof StringSchema
182
- ? value
183
- : value === 'true'
184
- ? true
185
- : value === 'false'
186
- ? false
187
- : /^-?\d+$/.test(value)
188
- ? Number(value)
189
- : value
190
-
191
- const currentParam = params[key]
219
+ ? unwrapSchema(innerValidator.validator)
220
+ : innerValidator
221
+
222
+ const coerced = coerceParam(name, value, scalarValidator, options)
223
+
224
+ const currentParam = params[name]
192
225
  if (currentParam === undefined) {
193
- params[key] = expectsArray ? [coerced] : coerced
226
+ params[name] = expectsArray ? [coerced] : coerced
194
227
  } else if (Array.isArray(currentParam)) {
195
228
  currentParam.push(coerced)
196
229
  } else {
197
- params[key] = [currentParam, coerced]
230
+ params[name] = [currentParam, coerced]
198
231
  }
199
232
  }
200
233
 
201
- return this.parse(params)
234
+ return this.parse(params, options)
202
235
  }
203
236
 
204
237
  toURLSearchParams(input: InferInput<this>): URLSearchParams {
@@ -222,6 +255,63 @@ export class ParamsSchema<
222
255
  }
223
256
  }
224
257
 
258
+ function coerceParam(
259
+ name: string,
260
+ param: string,
261
+ schema?: ParamScalarValidator,
262
+ options?: ParseOptions,
263
+ ): ParamScalar {
264
+ let issue: Issue
265
+
266
+ if (!schema) {
267
+ // The param is unknown (not defined in schema), so we don't apply any
268
+ // coercion and just return the string value.
269
+ return param
270
+ } else if (schema instanceof StringSchema) {
271
+ return param
272
+ } else if (schema instanceof IntegerSchema) {
273
+ if (/^-?\d+$/.test(param)) return Number(param)
274
+ issue = new IssueInvalidType(paramPath(name, options), param, ['integer'])
275
+ } else if (schema instanceof BooleanSchema) {
276
+ if (param === 'true') return true
277
+ if (param === 'false') return false
278
+ issue = new IssueInvalidType(paramPath(name, options), param, ['boolean'])
279
+ } else if (schema instanceof LiteralSchema) {
280
+ const { value } = schema
281
+ if (String(value) === param) return value
282
+ issue = new IssueInvalidValue(paramPath(name, options), param, [value])
283
+ } else if (schema instanceof EnumSchema) {
284
+ const { values } = schema
285
+ for (const value of values) {
286
+ if (String(value) === param) return value
287
+ }
288
+ issue = new IssueInvalidValue(paramPath(name, options), param, values)
289
+ } else {
290
+ // This should never happen. If it *does*, it means that the user of
291
+ // lex-schema is mixing different versions of the lib, which is not
292
+ // supported. Throwing an error here is better than silently accepting
293
+ // invalid params and causing unexpected behavior down the line (ie. error
294
+ // message returning the string value instead of the expected
295
+ // boolean/number/string value).
296
+ throw new Error(`Unsupported schema type for param coercion: ${schema}`)
297
+ }
298
+
299
+ // We were not able to coerce the param to the expected type. There is no
300
+ // point in returning the original string value since it doesn't conform to
301
+ // the expected schema, so we throw a validation error instead. We could
302
+ // return the "param" here, which would cause the validation to fail later on
303
+ // (see fromURLSearchParams()'s return statement). The main benefit of
304
+ // returning the original "param" value is that the error path would include
305
+ // the index of the param in case of array params (e.g. "tags[1]"), which
306
+ // could be helpful for debugging. The cost overhead is not worth it though
307
+ // (IMO).
308
+ throw new LexValidationError([issue])
309
+ }
310
+
311
+ function paramPath(key: string, options?: ParseOptions) {
312
+ return options?.path ? [...options.path, key] : [key]
313
+ }
314
+
225
315
  /**
226
316
  * Creates a params schema for URL query parameters.
227
317
  *
@@ -258,17 +348,24 @@ export class ParamsSchema<
258
348
  * ```
259
349
  */
260
350
  export const params = /*#__PURE__*/ memoizedOptions(function params<
261
- const TShape extends ParamsSchemaShape = NonNullable<unknown>,
351
+ const TShape extends ParamsShape = NonNullable<unknown>,
262
352
  >(properties: TShape = {} as TShape) {
263
353
  return new ParamsSchema<TShape>(properties)
264
354
  })
265
355
 
266
- function unwrapValidator(schema?: Validator): Validator | undefined {
356
+ type UnwrapSchema<S extends Validator> =
357
+ S extends OptionalSchema<infer U>
358
+ ? UnwrapSchema<U>
359
+ : S extends WithDefaultSchema<infer U>
360
+ ? UnwrapSchema<U>
361
+ : S
362
+
363
+ function unwrapSchema<S extends Validator>(schema: S): UnwrapSchema<S> {
267
364
  while (
268
365
  schema instanceof OptionalSchema ||
269
366
  schema instanceof WithDefaultSchema
270
367
  ) {
271
- schema = schema.validator
368
+ return unwrapSchema(schema.validator)
272
369
  }
273
- return schema
370
+ return schema as UnwrapSchema<S>
274
371
  }
@@ -1,9 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { integer } from './integer.js'
3
+ import { lexMap } from './lex-map.js'
3
4
  import { object } from './object.js'
4
5
  import { payload } from './payload.js'
5
6
  import { string } from './string.js'
6
- import { unknownObject } from './unknown-object.js'
7
7
 
8
8
  describe('Payload', () => {
9
9
  describe('basic construction', () => {
@@ -224,7 +224,7 @@ describe('Payload', () => {
224
224
  'application/json',
225
225
  object({
226
226
  success: string(),
227
- data: unknownObject(),
227
+ data: lexMap(),
228
228
  }),
229
229
  )
230
230
  expect(def.encoding).toBe('application/json')
@@ -107,15 +107,13 @@ export class Payload<
107
107
  * encoding.
108
108
  */
109
109
  matchesEncoding(contentType: string | undefined): boolean {
110
- const mime = contentType?.split(';', 1)[0].trim()
111
-
112
110
  const { encoding } = this
113
111
 
114
112
  // Handle undefined cases
115
113
  if (encoding === undefined) {
116
114
  // Expecting no body
117
- return mime === undefined
118
- } else if (mime === undefined) {
115
+ return contentType == null
116
+ } else if (contentType == null) {
119
117
  // Expecting a body, but got no content-type
120
118
  return false
121
119
  }
@@ -124,6 +122,7 @@ export class Payload<
124
122
  return true
125
123
  }
126
124
 
125
+ const mime = contentType?.split(';', 1)[0].trim()
127
126
  if (encoding.endsWith('/*')) {
128
127
  return mime.startsWith(encoding.slice(0, -1))
129
128
  }
@@ -11,6 +11,7 @@ import {
11
11
  ValidationContext,
12
12
  Validator,
13
13
  } from '../core.js'
14
+ import { lazyProperty } from '../util/lazy-property.js'
14
15
  import { literal } from './literal.js'
15
16
  import { string } from './string.js'
16
17
 
@@ -22,6 +23,13 @@ import { string } from './string.js'
22
23
  export type InferRecordKey<R extends RecordSchema> =
23
24
  R extends RecordSchema<infer TKey> ? RecordKeySchemaOutput<TKey> : never
24
25
 
26
+ export type TypedRecord<
27
+ TType extends NsidString,
28
+ TValue extends { $type?: unknown } = { $type?: unknown },
29
+ > = TValue extends { $type: TType }
30
+ ? TValue
31
+ : $Typed<Exclude<TValue, Unknown$TypedObject>, TType>
32
+
25
33
  /**
26
34
  * Schema for AT Protocol records with a type identifier and key constraints.
27
35
  *
@@ -50,6 +58,8 @@ export class RecordSchema<
50
58
  $Typed<InferInput<TShape>, TType>,
51
59
  $Typed<InferOutput<TShape>, TType>
52
60
  > {
61
+ readonly type = 'record' as const
62
+
53
63
  keySchema: RecordKeySchema<TKey>
54
64
 
55
65
  constructor(
@@ -61,28 +71,6 @@ export class RecordSchema<
61
71
  this.keySchema = recordKey(key)
62
72
  }
63
73
 
64
- isTypeOf<X extends { $type?: unknown }>(
65
- value: X,
66
- ): value is X extends { $type: TType }
67
- ? X
68
- : $Typed<Exclude<X, Unknown$TypedObject>, TType> {
69
- return value.$type === this.$type
70
- }
71
-
72
- build(
73
- input: Omit<InferInput<this>, '$type'>,
74
- ): $Typed<InferOutput<this>, TType> {
75
- return this.parse($typed(input, this.$type))
76
- }
77
-
78
- $isTypeOf<X extends { $type?: unknown }>(value: X) {
79
- return this.isTypeOf<X>(value)
80
- }
81
-
82
- $build(input: Omit<InferInput<this>, '$type'>) {
83
- return this.build(input)
84
- }
85
-
86
74
  validateInContext(input: unknown, ctx: ValidationContext) {
87
75
  const result = ctx.validate(input, this.schema)
88
76
 
@@ -96,6 +84,34 @@ export class RecordSchema<
96
84
 
97
85
  return result
98
86
  }
87
+
88
+ build(
89
+ input: Omit<InferInput<this>, '$type'>,
90
+ ): $Typed<InferOutput<this>, TType> {
91
+ return this.parse($typed(input, this.$type))
92
+ }
93
+
94
+ isTypeOf<TValue extends { $type?: unknown }>(
95
+ value: TValue,
96
+ ): value is TypedRecord<TType, TValue> {
97
+ return value.$type === this.$type
98
+ }
99
+
100
+ /**
101
+ * Bound alias for {@link build} for compatibility with generated utilities.
102
+ * @see {@link build}
103
+ */
104
+ get $build(): typeof this.build {
105
+ return lazyProperty(this, '$build', this.build.bind(this))
106
+ }
107
+
108
+ /**
109
+ * Bound alias for {@link isTypeOf} for compatibility with generated utilities.
110
+ * @see {@link isTypeOf}
111
+ */
112
+ get $isTypeOf(): typeof this.isTypeOf {
113
+ return lazyProperty(this, '$isTypeOf', this.isTypeOf.bind(this))
114
+ }
99
115
  }
100
116
 
101
117
  export type RecordKeySchemaOutput<Key extends LexiconRecordKey> =
package/src/schema/ref.ts CHANGED
@@ -39,6 +39,8 @@ export class RefSchema<const TValidator extends Validator>
39
39
  >
40
40
  implements WrappedValidator<TValidator>
41
41
  {
42
+ readonly type = 'ref' as const
43
+
42
44
  #getter: RefSchemaGetter<TValidator>
43
45
 
44
46
  constructor(getter: RefSchemaGetter<TValidator>) {
@@ -18,13 +18,15 @@ import { Schema, ValidationContext } from '../core.js'
18
18
  export class RegexpSchema<
19
19
  TValue extends string = string,
20
20
  > extends Schema<TValue> {
21
+ readonly type = 'regexp' as const
22
+
21
23
  constructor(public readonly pattern: RegExp) {
22
24
  super()
23
25
  }
24
26
 
25
27
  validateInContext(input: unknown, ctx: ValidationContext) {
26
28
  if (typeof input !== 'string') {
27
- return ctx.issueInvalidType(input, 'string')
29
+ return ctx.issueUnexpectedType(input, 'string')
28
30
  }
29
31
 
30
32
  if (!this.pattern.test(input)) {