@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
@@ -3,8 +3,13 @@ import {
3
3
  Infer,
4
4
  InferInput,
5
5
  InferOutput,
6
+ Issue,
7
+ IssueInvalidType,
8
+ IssueInvalidValue,
9
+ ParseOptions,
6
10
  Schema,
7
11
  ValidationContext,
12
+ ValidationError,
8
13
  Validator,
9
14
  WithOptionalProperties,
10
15
  } from '../core.js'
@@ -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,38 @@ 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
+ const validator = this.shapeValidators.get(name)
213
+ const innerValidator = validator ? unwrapSchema(validator) : undefined
214
+ const expectsArray = innerValidator instanceof ArraySchema
176
215
  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]
216
+ ? unwrapSchema(innerValidator.validator)
217
+ : innerValidator
218
+
219
+ const coerced = coerceParam(name, value, scalarValidator, options)
220
+
221
+ const currentParam = params[name]
192
222
  if (currentParam === undefined) {
193
- params[key] = expectsArray ? [coerced] : coerced
223
+ params[name] = expectsArray ? [coerced] : coerced
194
224
  } else if (Array.isArray(currentParam)) {
195
225
  currentParam.push(coerced)
196
226
  } else {
197
- params[key] = [currentParam, coerced]
227
+ params[name] = [currentParam, coerced]
198
228
  }
199
229
  }
200
230
 
201
- return this.parse(params)
231
+ return this.parse(params, options)
202
232
  }
203
233
 
204
234
  toURLSearchParams(input: InferInput<this>): URLSearchParams {
@@ -222,6 +252,63 @@ export class ParamsSchema<
222
252
  }
223
253
  }
224
254
 
255
+ function coerceParam(
256
+ name: string,
257
+ param: string,
258
+ schema?: ParamScalarValidator,
259
+ options?: ParseOptions,
260
+ ): ParamScalar {
261
+ let issue: Issue
262
+
263
+ if (!schema) {
264
+ // The param is unknown (not defined in schema), so we don't apply any
265
+ // coercion and just return the string value.
266
+ return param
267
+ } else if (schema instanceof StringSchema) {
268
+ return param
269
+ } else if (schema instanceof IntegerSchema) {
270
+ if (/^-?\d+$/.test(param)) return Number(param)
271
+ issue = new IssueInvalidType(paramPath(name, options), param, ['integer'])
272
+ } else if (schema instanceof BooleanSchema) {
273
+ if (param === 'true') return true
274
+ if (param === 'false') return false
275
+ issue = new IssueInvalidType(paramPath(name, options), param, ['boolean'])
276
+ } else if (schema instanceof LiteralSchema) {
277
+ const { value } = schema
278
+ if (String(value) === param) return value
279
+ issue = new IssueInvalidValue(paramPath(name, options), param, [value])
280
+ } else if (schema instanceof EnumSchema) {
281
+ const { values } = schema
282
+ for (const value of values) {
283
+ if (String(value) === param) return value
284
+ }
285
+ issue = new IssueInvalidValue(paramPath(name, options), param, values)
286
+ } else {
287
+ // This should never happen. If it *does*, it means that the user of
288
+ // lex-schema is mixing different versions of the lib, which is not
289
+ // supported. Throwing an error here is better than silently accepting
290
+ // invalid params and causing unexpected behavior down the line (ie. error
291
+ // message returning the string value instead of the expected
292
+ // boolean/number/string value).
293
+ throw new Error(`Unsupported schema type for param coercion: ${schema}`)
294
+ }
295
+
296
+ // We were not able to coerce the param to the expected type. There is no
297
+ // point in returning the original string value since it doesn't conform to
298
+ // the expected schema, so we throw a validation error instead. We could
299
+ // return the "param" here, which would cause the validation to fail later on
300
+ // (see fromURLSearchParams()'s return statement). The main benefit of
301
+ // returning the original "param" value is that the error path would include
302
+ // the index of the param in case of array params (e.g. "tags[1]"), which
303
+ // could be helpful for debugging. The cost overhead is not worth it though
304
+ // (IMO).
305
+ throw new ValidationError([issue])
306
+ }
307
+
308
+ function paramPath(key: string, options?: ParseOptions) {
309
+ return options?.path ? [...options.path, key] : [key]
310
+ }
311
+
225
312
  /**
226
313
  * Creates a params schema for URL query parameters.
227
314
  *
@@ -258,17 +345,24 @@ export class ParamsSchema<
258
345
  * ```
259
346
  */
260
347
  export const params = /*#__PURE__*/ memoizedOptions(function params<
261
- const TShape extends ParamsSchemaShape = NonNullable<unknown>,
348
+ const TShape extends ParamsShape = NonNullable<unknown>,
262
349
  >(properties: TShape = {} as TShape) {
263
350
  return new ParamsSchema<TShape>(properties)
264
351
  })
265
352
 
266
- function unwrapValidator(schema?: Validator): Validator | undefined {
353
+ type UnwrapSchema<S extends Validator> =
354
+ S extends OptionalSchema<infer U>
355
+ ? UnwrapSchema<U>
356
+ : S extends WithDefaultSchema<infer U>
357
+ ? UnwrapSchema<U>
358
+ : S
359
+
360
+ function unwrapSchema<S extends Validator>(schema: S): UnwrapSchema<S> {
267
361
  while (
268
362
  schema instanceof OptionalSchema ||
269
363
  schema instanceof WithDefaultSchema
270
364
  ) {
271
- schema = schema.validator
365
+ return unwrapSchema(schema.validator)
272
366
  }
273
- return schema
367
+ return schema as UnwrapSchema<S>
274
368
  }
@@ -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
  }
@@ -22,6 +22,13 @@ import { string } from './string.js'
22
22
  export type InferRecordKey<R extends RecordSchema> =
23
23
  R extends RecordSchema<infer TKey> ? RecordKeySchemaOutput<TKey> : never
24
24
 
25
+ export type TypedRecord<
26
+ TType extends NsidString,
27
+ TValue extends { $type?: unknown } = { $type?: unknown },
28
+ > = TValue extends { $type: TType }
29
+ ? TValue
30
+ : $Typed<Exclude<TValue, Unknown$TypedObject>, TType>
31
+
25
32
  /**
26
33
  * Schema for AT Protocol records with a type identifier and key constraints.
27
34
  *
@@ -50,6 +57,8 @@ export class RecordSchema<
50
57
  $Typed<InferInput<TShape>, TType>,
51
58
  $Typed<InferOutput<TShape>, TType>
52
59
  > {
60
+ readonly type = 'record' as const
61
+
53
62
  keySchema: RecordKeySchema<TKey>
54
63
 
55
64
  constructor(
@@ -61,11 +70,9 @@ export class RecordSchema<
61
70
  this.keySchema = recordKey(key)
62
71
  }
63
72
 
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> {
73
+ isTypeOf<TValue extends { $type?: unknown }>(
74
+ value: TValue,
75
+ ): value is TypedRecord<TType, TValue> {
69
76
  return value.$type === this.$type
70
77
  }
71
78
 
@@ -75,11 +82,15 @@ export class RecordSchema<
75
82
  return this.parse($typed(input, this.$type))
76
83
  }
77
84
 
78
- $isTypeOf<X extends { $type?: unknown }>(value: X) {
79
- return this.isTypeOf<X>(value)
85
+ $isTypeOf<TValue extends { $type?: unknown }>(
86
+ value: TValue,
87
+ ): value is TypedRecord<TType, TValue> {
88
+ return this.isTypeOf<TValue>(value)
80
89
  }
81
90
 
82
- $build(input: Omit<InferInput<this>, '$type'>) {
91
+ $build(
92
+ input: Omit<InferInput<this>, '$type'>,
93
+ ): $Typed<InferOutput<this>, TType> {
83
94
  return this.build(input)
84
95
  }
85
96
 
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)) {
@@ -1,5 +1,6 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { string } from './string.js'
1
+ import { describe, expect, expectTypeOf, it } from 'vitest'
2
+ import { Infer, UnknownString } from '../core.js'
3
+ import { StringSchemaOptions, string } from './string.js'
3
4
  import { token } from './token.js'
4
5
  import { withDefault } from './with-default.js'
5
6
 
@@ -610,4 +611,100 @@ describe('StringSchema', () => {
610
611
  expect(result.success).toBe(true)
611
612
  })
612
613
  })
614
+
615
+ describe('knownValues option', () => {
616
+ it('allows omitting knownValues at runtime', () => {
617
+ string<{ knownValues: ['active', 'inactive'] }>()
618
+
619
+ // @ts-expect-error format requires options to be set
620
+ string<{ knownValues: ['active', 'inactive']; format: 'did' }>()
621
+
622
+ // @ts-expect-error any options, besides knownValues, must be provided
623
+ string<{ knownValues: ['active', 'inactive']; minLength: 5 }>()
624
+
625
+ string<{
626
+ knownValues: ['john.doe', 'someone.else']
627
+ format: 'handle'
628
+ }>({
629
+ format: 'handle',
630
+ })
631
+
632
+ string<{
633
+ knownValues: ['john.doe', 'someone.else']
634
+ }>({
635
+ // Being *more* precise than the generic if fine
636
+ format: 'handle',
637
+ })
638
+
639
+ string<{
640
+ knownValues: ['did', 'inactive']
641
+ format: 'did'
642
+ }>({
643
+ // @ts-expect-error does not match format form generic constraint
644
+ format: 'handle',
645
+ })
646
+
647
+ string<{
648
+ knownValues: ['active', 'inactive']
649
+ minLength: 10
650
+ }>({
651
+ minLength: 10,
652
+ })
653
+
654
+ string<{
655
+ knownValues: ['active', 'inactive']
656
+ minLength: 5
657
+ }>({
658
+ // @ts-expect-error mismatch
659
+ minLength: 10,
660
+ })
661
+ })
662
+ })
663
+
664
+ it('properly types knownValues in parameters', () => {
665
+ const schema = string({
666
+ knownValues: ['active', 'inactive'],
667
+ })
668
+ type SchemaType = Infer<typeof schema>
669
+ expectTypeOf<{
670
+ foo: SchemaType
671
+ }>().toMatchObjectType<{
672
+ foo: 'active' | 'inactive' | UnknownString
673
+ }>()
674
+ expectTypeOf<{
675
+ foo: SchemaType
676
+ }>().not.toMatchObjectType<{
677
+ foo: string
678
+ }>()
679
+ expectTypeOf<{
680
+ foo: SchemaType
681
+ }>().not.toMatchObjectType<{
682
+ foo: 'active' | 'inactive'
683
+ }>()
684
+ expectTypeOf<{
685
+ foo: SchemaType
686
+ }>().not.toMatchObjectType<{
687
+ foo: UnknownString
688
+ }>()
689
+ })
690
+
691
+ it('type string<any>() as string', () => {
692
+ const schema = string<any>()
693
+ type SchemaType = Infer<typeof schema>
694
+ expectTypeOf<{
695
+ foo: SchemaType
696
+ }>().toMatchObjectType<{
697
+ foo: string
698
+ }>()
699
+ })
700
+
701
+ it('type string<StringSchemaOptions>({}) as string', () => {
702
+ const schema = string<StringSchemaOptions>({})
703
+ type SchemaType = Infer<typeof schema>
704
+ expectTypeOf<{
705
+ foo: SchemaType
706
+ }>().toMatchObjectType<{
707
+ foo: string
708
+ }>()
709
+ })
613
710
  })
@@ -1,11 +1,14 @@
1
1
  import { graphemeLen, ifCid, utf8Len } from '@atproto/lex-data'
2
2
  import {
3
3
  InferStringFormat,
4
+ Restricted,
4
5
  Schema,
5
6
  StringFormat,
7
+ UnknownString,
6
8
  ValidationContext,
7
9
  isStringFormat,
8
10
  } from '../core.js'
11
+ import { IfAny } from '../util/if-any.js'
9
12
  import { memoizedOptions } from '../util/memoize.js'
10
13
  import { TokenSchema } from './token.js'
11
14
 
@@ -13,6 +16,7 @@ import { TokenSchema } from './token.js'
13
16
  * Configuration options for string schema validation.
14
17
  *
15
18
  * @property format - Expected string format (e.g., 'datetime', 'uri', 'at-uri', 'did', 'handle', 'nsid', 'cid', 'tid', 'record-key', 'at-identifier', 'language')
19
+ * @property knownValues - Known string literal values for type narrowing
16
20
  * @property minLength - Minimum length in UTF-8 bytes
17
21
  * @property maxLength - Maximum length in UTF-8 bytes
18
22
  * @property minGraphemes - Minimum number of grapheme clusters
@@ -20,6 +24,7 @@ import { TokenSchema } from './token.js'
20
24
  */
21
25
  export type StringSchemaOptions = {
22
26
  format?: StringFormat
27
+ knownValues?: readonly string[]
23
28
  minLength?: number
24
29
  maxLength?: number
25
30
  minGraphemes?: number
@@ -43,30 +48,46 @@ export type StringSchemaOptions = {
43
48
  export class StringSchema<
44
49
  const TOptions extends StringSchemaOptions = StringSchemaOptions,
45
50
  > extends Schema<
46
- TOptions extends { format: infer F extends StringFormat }
47
- ? InferStringFormat<F>
48
- : string
51
+ IfAny<
52
+ TOptions,
53
+ string,
54
+ TOptions extends { format: infer F extends StringFormat }
55
+ ? InferStringFormat<F>
56
+ : TOptions extends { knownValues: readonly (infer V extends string)[] }
57
+ ? V | UnknownString
58
+ : string
59
+ >
49
60
  > {
50
- constructor(readonly options?: TOptions) {
61
+ readonly type = 'string' as const
62
+
63
+ // @NOTE since the _string utility allows omitting knownValues when TOptions
64
+ // *does* include it (since it's only used for typing), we cannot type options
65
+ // as TOptions directly since it may not actually include knownValues at
66
+ // runtime, making schema.options.knownValues potentially undefined even when
67
+ // TOptions includes it.
68
+ readonly options: StringSchemaOptions
69
+
70
+ constructor(options: TOptions) {
51
71
  super()
72
+ this.options = options
52
73
  }
53
74
 
54
75
  validateInContext(input: unknown, ctx: ValidationContext) {
55
76
  const str = coerceToString(input)
56
77
  if (str == null) {
57
- return ctx.issueInvalidType(input, 'string')
78
+ return ctx.issueUnexpectedType(input, 'string')
58
79
  }
59
80
 
60
81
  let lazyUtf8Len: number
61
82
 
62
- const minLength = this.options?.minLength
83
+ const minLength = this.options.minLength
63
84
  if (minLength != null) {
64
85
  if ((lazyUtf8Len ??= utf8Len(str)) < minLength) {
65
86
  return ctx.issueTooSmall(str, 'string', minLength, lazyUtf8Len)
66
87
  }
67
88
  }
68
89
 
69
- const maxLength = this.options?.maxLength
90
+ const maxLength = this.options.maxLength
70
91
  if (maxLength != null) {
71
92
  // Optimization: we can avoid computing the UTF-8 length if the maximum
72
93
  // possible length, in bytes, of the input JS string is smaller than the
@@ -80,7 +101,7 @@ export class StringSchema<
80
101
 
81
102
  let lazyGraphLen: number
82
103
 
83
- const minGraphemes = this.options?.minGraphemes
104
+ const minGraphemes = this.options.minGraphemes
84
105
  if (minGraphemes != null) {
85
106
  // Optimization: avoid counting graphemes if the length check already fails
86
107
  if (str.length < minGraphemes) {
@@ -90,14 +111,14 @@ export class StringSchema<
90
111
  }
91
112
  }
92
113
 
93
- const maxGraphemes = this.options?.maxGraphemes
114
+ const maxGraphemes = this.options.maxGraphemes
94
115
  if (maxGraphemes != null) {
95
116
  if ((lazyGraphLen ??= graphemeLen(str)) > maxGraphemes) {
96
117
  return ctx.issueTooBig(str, 'grapheme', maxGraphemes, lazyGraphLen)
97
118
  }
98
119
  }
99
120
 
100
- const format = this.options?.format
121
+ const format = this.options.format
101
122
  if (format != null && !isStringFormat(str, format)) {
102
123
  return ctx.issueInvalidFormat(str, format)
103
124
  }
@@ -146,6 +167,32 @@ export function coerceToString(input: unknown): string | null {
146
167
  }
147
168
  }
148
169
 
170
+ function _string(): StringSchema<NonNullable<unknown>>
171
+ function _string<
172
+ // Allow calling `string<{ knownValues: [...] }>()` without passing an options
173
+ // object, since knownValues is only used for typing and has no runtime
174
+ // effect, so it can be safely omitted at runtime.
175
+ const TOptions extends {
176
+ knownValues: StringSchemaOptions['knownValues']
177
+ } & {
178
+ [K in Exclude<
179
+ keyof StringSchemaOptions,
180
+ 'knownValues'
181
+ >]?: Restricted<`An options argument is required when using the "${K}" option`>
182
+ },
183
+ >(): StringSchema<
184
+ IfAny<TOptions, any, { knownValues: TOptions['knownValues'] }>
185
+ >
186
+ function _string<const TOptions extends StringSchemaOptions>(
187
+ // If TOptions is explicitly provided (e.g. `string<{ ... }>({ ... })`), we
188
+ // allow the actual options argument to omit the "knownValues" property since
189
+ // it's only used for inferring the type and has no runtime effect.
190
+ options: TOptions | Omit<TOptions, 'knownValues'>,
191
+ ): StringSchema<TOptions>
192
+ function _string(options: StringSchemaOptions = {}) {
193
+ return new StringSchema(options)
194
+ }
195
+
149
196
  /**
150
197
  * Creates a string schema with optional format and length constraints.
151
198
  *
@@ -173,8 +220,4 @@ export function coerceToString(input: unknown): string | null {
173
220
  * const handleSchema = l.string({ format: 'handle', minLength: 3, maxLength: 253 })
174
221
  * ```
175
222
  */
176
- export const string = /*#__PURE__*/ memoizedOptions(function <
177
- const O extends StringSchemaOptions = NonNullable<unknown>,
178
- >(options?: StringSchemaOptions & O) {
179
- return new StringSchema<O>(options)
180
- })
223
+ export const string = /*#__PURE__*/ memoizedOptions(_string)
@@ -18,6 +18,8 @@ import { $type, NsidString, Schema, ValidationContext } from '../core.js'
18
18
  export class TokenSchema<
19
19
  const TValue extends string = string,
20
20
  > extends Schema<TValue> {
21
+ readonly type = 'token' as const
22
+
21
23
  constructor(readonly value: TValue) {
22
24
  super()
23
25
  }
@@ -34,7 +36,7 @@ export class TokenSchema<
34
36
  }
35
37
 
36
38
  if (typeof input !== 'string') {
37
- return ctx.issueInvalidType(input, 'token')
39
+ return ctx.issueUnexpectedType(input, 'token')
38
40
  }
39
41
 
40
42
  return ctx.issueInvalidValue(input, [this.value])