@atproto/lex-schema 0.0.16 → 0.0.18

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 (55) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/core/schema.d.ts +5 -11
  3. package/dist/core/schema.d.ts.map +1 -1
  4. package/dist/core/schema.js +10 -16
  5. package/dist/core/schema.js.map +1 -1
  6. package/dist/core/validation-error.d.ts +2 -2
  7. package/dist/core/validation-error.d.ts.map +1 -1
  8. package/dist/core/validation-error.js +1 -1
  9. package/dist/core/validation-error.js.map +1 -1
  10. package/dist/core/validation-issue.d.ts.map +1 -1
  11. package/dist/core/validation-issue.js +19 -38
  12. package/dist/core/validation-issue.js.map +1 -1
  13. package/dist/helpers.d.ts +4 -3
  14. package/dist/helpers.d.ts.map +1 -1
  15. package/dist/helpers.js +6 -1
  16. package/dist/helpers.js.map +1 -1
  17. package/dist/schema/blob.d.ts +6 -20
  18. package/dist/schema/blob.d.ts.map +1 -1
  19. package/dist/schema/blob.js +23 -28
  20. package/dist/schema/blob.js.map +1 -1
  21. package/dist/schema/payload.d.ts.map +1 -1
  22. package/dist/schema/payload.js +2 -3
  23. package/dist/schema/payload.js.map +1 -1
  24. package/dist/schema/record.d.ts +6 -8
  25. package/dist/schema/record.d.ts.map +1 -1
  26. package/dist/schema/record.js +1 -1
  27. package/dist/schema/record.js.map +1 -1
  28. package/dist/schema/regexp.d.ts +3 -2
  29. package/dist/schema/regexp.d.ts.map +1 -1
  30. package/dist/schema/regexp.js +6 -4
  31. package/dist/schema/regexp.js.map +1 -1
  32. package/dist/schema/string.d.ts.map +1 -1
  33. package/dist/schema/string.js +9 -2
  34. package/dist/schema/string.js.map +1 -1
  35. package/dist/schema/typed-object.d.ts +5 -7
  36. package/dist/schema/typed-object.d.ts.map +1 -1
  37. package/dist/schema/typed-object.js +1 -1
  38. package/dist/schema/typed-object.js.map +1 -1
  39. package/package.json +3 -3
  40. package/src/core/$type.test.ts +9 -5
  41. package/src/core/schema.ts +19 -16
  42. package/src/core/validation-error.ts +2 -2
  43. package/src/core/validation-issue.ts +20 -36
  44. package/src/helpers.ts +7 -1
  45. package/src/schema/array.test.ts +1 -1
  46. package/src/schema/blob.test.ts +223 -263
  47. package/src/schema/blob.ts +27 -46
  48. package/src/schema/params.test.ts +2 -2
  49. package/src/schema/payload.ts +2 -3
  50. package/src/schema/record.test.ts +135 -17
  51. package/src/schema/record.ts +14 -9
  52. package/src/schema/regexp.ts +14 -4
  53. package/src/schema/string.ts +8 -2
  54. package/src/schema/typed-object.test.ts +77 -0
  55. package/src/schema/typed-object.ts +11 -10
@@ -1,9 +1,11 @@
1
1
  import {
2
2
  BlobRef,
3
3
  LegacyBlobRef,
4
+ TypedBlobRef,
5
+ getBlobSize,
4
6
  isBlobRef,
5
7
  isLegacyBlobRef,
6
- parseCidSafe,
8
+ isTypedBlobRef,
7
9
  } from '@atproto/lex-data'
8
10
  import { Schema, ValidationContext } from '../core.js'
9
11
  import { memoizedOptions } from '../util/memoize.js'
@@ -12,14 +14,6 @@ import { memoizedOptions } from '../util/memoize.js'
12
14
  * Configuration options for blob schema validation.
13
15
  */
14
16
  export type BlobSchemaOptions = {
15
- /**
16
- * Whether to allow legacy blob references format
17
- *
18
- * @default false
19
- * @see {@link LegacyBlobRef}
20
- */
21
- allowLegacy?: boolean
22
-
23
17
  /**
24
18
  * List of accepted MIME types (supports wildcards like 'image/*' or '*\/*')
25
19
  *
@@ -35,8 +29,8 @@ export type BlobSchemaOptions = {
35
29
  maxSize?: number
36
30
  }
37
31
 
38
- export type { BlobRef, LegacyBlobRef }
39
- export { isBlobRef, isLegacyBlobRef }
32
+ export type { BlobRef, LegacyBlobRef, TypedBlobRef }
33
+ export { isBlobRef, isLegacyBlobRef, isTypedBlobRef }
40
34
 
41
35
  /**
42
36
  * Schema for validating blob references in AT Protocol.
@@ -55,9 +49,7 @@ export { isBlobRef, isLegacyBlobRef }
55
49
  */
56
50
  export class BlobSchema<
57
51
  const TOptions extends BlobSchemaOptions = NonNullable<unknown>,
58
- > extends Schema<
59
- TOptions extends { allowLegacy: true } ? BlobRef | LegacyBlobRef : BlobRef
60
- > {
52
+ > extends Schema<BlobRef> {
61
53
  readonly type = 'blob' as const
62
54
 
63
55
  constructor(readonly options?: TOptions) {
@@ -65,23 +57,29 @@ export class BlobSchema<
65
57
  }
66
58
 
67
59
  validateInContext(input: unknown, ctx: ValidationContext) {
68
- const blob = parseValue.call(ctx, input, this.options)
69
-
60
+ const blob = parseValue.call(ctx, input)
70
61
  if (!blob) {
71
62
  return ctx.issueUnexpectedType(input, 'blob')
72
63
  }
73
64
 
74
65
  // In non-strict mode, we allow blob refs to pass through without MIME
75
66
  // type or size checks.
76
- if (ctx.options.strict) {
77
- const accept = this.options?.accept
67
+ if (ctx.options.strict && this.options != null) {
68
+ const { accept } = this.options
78
69
  if (accept && !matchesMime(blob.mimeType, accept)) {
79
70
  return ctx.issueInvalidPropertyValue(blob, 'mimeType', accept)
80
71
  }
81
72
 
82
- const maxSize = this.options?.maxSize
83
- if (maxSize != null && 'size' in blob && blob.size > maxSize) {
84
- return ctx.issueTooBig(blob, 'blob', maxSize, blob.size)
73
+ const { maxSize } = this.options
74
+ if (maxSize != null) {
75
+ const size = getBlobSize(blob)
76
+ if (size === undefined) {
77
+ // Unable to enforce size constraint if size is not available (legacy
78
+ // blob ref), so we treat it as a validation failure in strict mode.
79
+ return ctx.issueInvalidPropertyType(blob, 'size' as any, 'integer')
80
+ } else if (size > maxSize) {
81
+ return ctx.issueTooBig(blob, 'blob', maxSize, size)
82
+ }
85
83
  }
86
84
  }
87
85
 
@@ -95,33 +93,19 @@ export class BlobSchema<
95
93
  }
96
94
  }
97
95
 
98
- function parseValue(
99
- this: ValidationContext,
100
- input: unknown,
101
- options?: BlobSchemaOptions,
102
- ): BlobRef | LegacyBlobRef | null {
103
- // If there is a $type property, we treat if as a potential BlobRef and
96
+ function parseValue(this: ValidationContext, input: unknown): BlobRef | null {
97
+ // If there is a $type property, we treat if as a potential TypedBlobRef and
104
98
  // validate accordingly.
105
99
  if ((input as any)?.$type !== undefined) {
106
100
  // Use the context's option for the "strict" check
107
- return isBlobRef(input, this.options) ? input : null
101
+ return isTypedBlobRef(input, this.options) ? input : null
108
102
  }
109
103
 
110
104
  // If there is no $type property, we may be dealing with a legacy blob ref. If
111
- // legacy refs are allowed, validate against the legacy format. If not
112
- // allowed, but we are in non-strict "parse" mode, coerce legacy refs into
113
- // standard BlobRef format for backward compatibility. Otherwise, reject the
114
- // value.
115
- if (options?.allowLegacy) {
116
- if (isLegacyBlobRef(input)) {
117
- return input
118
- }
119
- } else if (!this.options.strict && this.options.mode === 'parse') {
120
- if (isLegacyBlobRef(input)) {
121
- const { cid, mimeType } = input
122
- const ref = parseCidSafe(cid)
123
- if (ref) return { $type: 'blob', ref, mimeType, size: -1 }
124
- }
105
+ // legacy refs are allowed (non-strict mode), we check if the input matches
106
+ // the legacy format.
107
+ if (!this.options.strict) {
108
+ if (isLegacyBlobRef(input, this.options)) return input
125
109
  }
126
110
 
127
111
  return null
@@ -157,13 +141,10 @@ function matchesMime(mime: string, accepted: string[]): boolean {
157
141
  *
158
142
  * // Any image type with size limit
159
143
  * const avatarSchema = l.blob({ accept: ['image/*'], maxSize: 1000000 })
160
- *
161
- * // Allow legacy format
162
- * const legacySchema = l.blob({ allowLegacy: true })
163
144
  * ```
164
145
  */
165
146
  export const blob = /*#__PURE__*/ memoizedOptions(function <
166
- O extends BlobSchemaOptions = { allowLegacy?: false },
147
+ O extends BlobSchemaOptions = NonNullable<unknown>,
167
148
  >(options?: O) {
168
149
  return new BlobSchema(options)
169
150
  })
@@ -419,7 +419,7 @@ describe('ParamsSchema', () => {
419
419
  ['name', 'Alice'],
420
420
  ['bools', 'notabool'],
421
421
  ]),
422
- ).toThrow('Expected boolean value type (got string) at $.bools')
422
+ ).toThrow('Expected boolean value type (got "notabool") at $.bools')
423
423
 
424
424
  expect(() =>
425
425
  schema.fromURLSearchParams(
@@ -431,7 +431,7 @@ describe('ParamsSchema', () => {
431
431
  path: ['foo', 'bar'],
432
432
  },
433
433
  ),
434
- ).toThrow('Expected boolean value type (got string) at $.foo.bar.bools')
434
+ ).toThrow('Expected boolean value type (got "2") at $.foo.bar.bools')
435
435
  })
436
436
 
437
437
  it('ignores empty string values', () => {
@@ -109,10 +109,9 @@ export class Payload<
109
109
  matchesEncoding(contentType: string | undefined): boolean {
110
110
  const { encoding } = this
111
111
 
112
- // Handle undefined cases
113
112
  if (encoding === undefined) {
114
- // Expecting no body
115
- return contentType == null
113
+ // When the output is not defined, we don't enforce any rule on the payload.
114
+ return true
116
115
  } else if (contentType == null) {
117
116
  // Expecting a body, but got no content-type
118
117
  return false
@@ -1,6 +1,8 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { Infer, Unknown$Type, Unknown$TypedObject } from '../core.js'
1
+ import { describe, expect, expectTypeOf, it } from 'vitest'
2
+ import { DidString, Infer, Unknown$Type, Unknown$TypedObject } from '../core.js'
3
+ import { integer } from './integer.js'
3
4
  import { object } from './object.js'
5
+ import { optional } from './optional.js'
4
6
  import { record } from './record.js'
5
7
  import { string } from './string.js'
6
8
 
@@ -123,41 +125,157 @@ describe('RecordSchema', () => {
123
125
  })
124
126
  })
125
127
 
126
- describe('build method', () => {
128
+ describe('build() method', () => {
127
129
  const schema = record(
128
130
  'any',
129
- 'app.bsky.feed.post',
131
+ 'io.example.record',
130
132
  object({
131
- $type: string(),
132
- text: string(),
133
+ actor: string({ format: 'did' }),
134
+ text: optional(string()),
133
135
  }),
134
136
  )
135
137
 
136
- it('adds correct $type to input', () => {
137
- const result = schema.build({ text: 'Hello world' })
138
- expect(result.$type).toBe('app.bsky.feed.post')
139
- expect(result.text).toBe('Hello world')
138
+ it('adds $type to input', () => {
139
+ const result = schema.build({
140
+ actor: 'did:foo:bar',
141
+ text: 'Hello',
142
+ })
143
+ expect(result).toStrictEqual({
144
+ $type: 'io.example.record',
145
+ actor: 'did:foo:bar',
146
+ text: 'Hello',
147
+ })
148
+ expectTypeOf(result).toEqualTypeOf<{
149
+ $type: 'io.example.record'
150
+ actor: DidString
151
+ text?: string
152
+ }>()
153
+ })
154
+
155
+ it('causes a type error for invalid values', () => {
156
+ const result = schema.build({
157
+ // @ts-expect-error
158
+ actor: 3,
159
+ text: 'Hello',
160
+ })
161
+ expectTypeOf(result).toEqualTypeOf<{
162
+ $type: 'io.example.record'
163
+ actor: DidString
164
+ text?: string
165
+ }>()
140
166
  })
141
167
 
142
168
  it('preserves existing properties', () => {
143
169
  const result = schema.build({
144
- text: 'Hello world',
170
+ actor: 'did:foo:bar',
145
171
  // @ts-expect-error
146
172
  extra: 'value',
147
173
  })
148
- expect(result.$type).toBe('app.bsky.feed.post')
149
- expect(result.text).toBe('Hello world')
150
- // @ts-expect-error
151
- expect(result.extra).toBe('value')
174
+ expect(result).toStrictEqual({
175
+ $type: 'io.example.record',
176
+ actor: 'did:foo:bar',
177
+ extra: 'value',
178
+ })
179
+ expectTypeOf(result).toEqualTypeOf<{
180
+ actor: `did:${string}:${string}`
181
+ text?: string | undefined
182
+ $type: 'io.example.record'
183
+ }>()
152
184
  })
153
185
 
154
186
  it('overwrites existing $type', () => {
155
187
  const result = schema.build({
156
188
  // @ts-expect-error
157
189
  $type: 'wrong.type',
158
- text: 'Hello world',
190
+ actor: 'did:foo:bar',
191
+ })
192
+ expect(result).toStrictEqual({
193
+ $type: 'io.example.record',
194
+ actor: 'did:foo:bar',
195
+ })
196
+ expectTypeOf(result).toEqualTypeOf<{
197
+ $type: 'io.example.record'
198
+ actor: DidString
199
+ text?: string
200
+ }>()
201
+ })
202
+
203
+ describe('build() does not validate', () => {
204
+ const schema = record(
205
+ 'any',
206
+ 'io.example.record',
207
+ object({
208
+ actor: string({ format: 'did' }),
209
+ count: integer(),
210
+ }),
211
+ )
212
+
213
+ it('does not throw for invalid data', () => {
214
+ const result = schema.build({
215
+ // @ts-expect-error
216
+ actor: 'not-a-did',
217
+ count: 123,
218
+ })
219
+
220
+ expect(result.$type).toBe('io.example.record')
221
+ expect(result.actor).toBe('not-a-did')
222
+ expect(result.count).toBe(123)
223
+ })
224
+
225
+ it('does not throw for invalid types', () => {
226
+ const result = schema.build({
227
+ actor: 'did:plc:abc123',
228
+ // @ts-expect-error
229
+ count: 'not-a-number',
230
+ })
231
+
232
+ expect(result.$type).toBe('io.example.record')
233
+ expect(result.count).toBe('not-a-number')
234
+ })
235
+
236
+ it('does not throw for missing required fields', () => {
237
+ // @ts-expect-error
238
+ const result = schema.build({})
239
+
240
+ expect(result.$type).toBe('io.example.record')
241
+ expect(result.actor).toBeUndefined()
242
+ expect(result.count).toBeUndefined()
243
+ })
244
+
245
+ it('does not throw for extra fields', () => {
246
+ const result = schema.build({
247
+ actor: 'did:plc:abc123',
248
+ count: 42,
249
+ // @ts-expect-error
250
+ extra: 'unexpected',
251
+ })
252
+
253
+ expect(result.$type).toBe('io.example.record')
254
+
255
+ // @ts-expect-error
256
+ expect(result.extra).toBe('unexpected')
257
+ })
258
+
259
+ it('parse() still validates after build()', () => {
260
+ const built = schema.build({
261
+ // @ts-expect-error
262
+ actor: 'not-a-did',
263
+ count: 123,
264
+ })
265
+
266
+ expect(() => schema.parse(built)).toThrow('Invalid DID')
267
+ })
268
+
269
+ it('safeParse() can detect validation errors after build()', () => {
270
+ const built = schema.build({
271
+ // @ts-expect-error
272
+ actor: 'not-a-did',
273
+ count: 123,
274
+ })
275
+
276
+ const result = schema.safeParse(built)
277
+ expect(result.success).toBe(false)
159
278
  })
160
- expect(result.$type).toBe('app.bsky.feed.post')
161
279
  })
162
280
  })
163
281
 
@@ -1,3 +1,4 @@
1
+ import { LexMap } from '@atproto/lex-data'
1
2
  import {
2
3
  $Typed,
3
4
  $typed,
@@ -51,9 +52,9 @@ export type TypedRecord<
51
52
  * ```
52
53
  */
53
54
  export class RecordSchema<
54
- const TKey extends LexiconRecordKey = any,
55
- const TType extends NsidString = any,
56
- const TShape extends Validator<{ [k: string]: unknown }> = any,
55
+ const TKey extends LexiconRecordKey = LexiconRecordKey,
56
+ const TType extends NsidString = NsidString,
57
+ const TShape extends Validator<LexMap> = Validator<LexMap>,
57
58
  > extends Schema<
58
59
  $Typed<InferInput<TShape>, TType>,
59
60
  $Typed<InferOutput<TShape>, TType>
@@ -86,9 +87,13 @@ export class RecordSchema<
86
87
  }
87
88
 
88
89
  build(
89
- input: Omit<InferInput<this>, '$type'>,
90
- ): $Typed<InferOutput<this>, TType> {
91
- return this.parse($typed(input, this.$type))
90
+ input: Omit<InferOutput<TShape>, '$type'>,
91
+ ): $Typed<InferOutput<TShape>, TType>
92
+ build(
93
+ input: Omit<InferInput<TShape>, '$type'>,
94
+ ): $Typed<InferInput<TShape>, TType>
95
+ build(input: Record<string, unknown>) {
96
+ return $typed(input, this.$type)
92
97
  }
93
98
 
94
99
  isTypeOf<TValue extends { $type?: unknown }>(
@@ -199,11 +204,11 @@ type AsNsid<T> = T extends `${string}#${string}` ? never : T
199
204
  export function record<
200
205
  const K extends LexiconRecordKey,
201
206
  const T extends NsidString,
202
- const S extends Validator<{ [k: string]: unknown }>,
207
+ const S extends Validator<LexMap>,
203
208
  >(key: K, type: AsNsid<T>, validator: S): RecordSchema<K, T, S>
204
209
  export function record<
205
210
  const K extends LexiconRecordKey,
206
- const V extends { $type: NsidString },
211
+ const V extends LexMap & { $type: NsidString },
207
212
  >(
208
213
  key: K,
209
214
  type: AsNsid<V['$type']>,
@@ -213,7 +218,7 @@ export function record<
213
218
  export function record<
214
219
  const K extends LexiconRecordKey,
215
220
  const T extends NsidString,
216
- const S extends Validator<{ [k: string]: unknown }>,
221
+ const S extends Validator<LexMap>,
217
222
  >(key: K, type: T, validator: S) {
218
223
  return new RecordSchema<K, T, S>(key, type, validator)
219
224
  }
@@ -20,7 +20,10 @@ export class RegexpSchema<
20
20
  > extends Schema<TValue> {
21
21
  readonly type = 'regexp' as const
22
22
 
23
- constructor(public readonly pattern: RegExp) {
23
+ constructor(
24
+ public readonly pattern: RegExp,
25
+ public readonly message?: string,
26
+ ) {
24
27
  super()
25
28
  }
26
29
 
@@ -30,7 +33,11 @@ export class RegexpSchema<
30
33
  }
31
34
 
32
35
  if (!this.pattern.test(input)) {
33
- return ctx.issueInvalidFormat(input, this.pattern.toString())
36
+ return ctx.issueInvalidFormat(
37
+ input,
38
+ this.pattern.toString(),
39
+ this.message,
40
+ )
34
41
  }
35
42
 
36
43
  return ctx.success(input as TValue)
@@ -67,6 +74,9 @@ export class RegexpSchema<
67
74
  * ```
68
75
  */
69
76
  /*@__NO_SIDE_EFFECTS__*/
70
- export function regexp<TInput extends string = string>(pattern: RegExp) {
71
- return new RegexpSchema<TInput>(pattern)
77
+ export function regexp<TInput extends string = string>(
78
+ pattern: RegExp,
79
+ message?: string,
80
+ ): RegexpSchema<TInput> {
81
+ return new RegexpSchema<TInput>(pattern, message)
72
82
  }
@@ -99,12 +99,15 @@ export class StringSchema<
99
99
  }
100
100
  }
101
101
 
102
+ // Optimization: count graphemes once
102
103
  let lazyGraphLen: number
103
104
 
104
105
  const minGraphemes = this.options.minGraphemes
105
106
  if (minGraphemes != null) {
106
- // Optimization: avoid counting graphemes if the length check already fails
107
107
  if (str.length < minGraphemes) {
108
+ // If the JavaScript string length (UTF-16) is below the minimal limit,
109
+ // its grapheme length (which <= .length) will also be below.
110
+ // Fail early.
108
111
  return ctx.issueTooSmall(str, 'grapheme', minGraphemes, str.length)
109
112
  } else if ((lazyGraphLen ??= graphemeLen(str)) < minGraphemes) {
110
113
  return ctx.issueTooSmall(str, 'grapheme', minGraphemes, lazyGraphLen)
@@ -113,7 +116,10 @@ export class StringSchema<
113
116
 
114
117
  const maxGraphemes = this.options.maxGraphemes
115
118
  if (maxGraphemes != null) {
116
- if ((lazyGraphLen ??= graphemeLen(str)) > maxGraphemes) {
119
+ if (str.length <= maxGraphemes) {
120
+ // If the JavaScript string length (UTF-16) is within the maximum limit,
121
+ // its grapheme length (which <= .length) will also be within.
122
+ } else if ((lazyGraphLen ??= graphemeLen(str)) > maxGraphemes) {
117
123
  return ctx.issueTooBig(str, 'grapheme', maxGraphemes, lazyGraphLen)
118
124
  }
119
125
  }
@@ -303,6 +303,83 @@ describe('TypedObjectSchema', () => {
303
303
  const result = emptySchema.build(input)
304
304
  expect(result).toEqual({ $type: 'app.bsky.test' })
305
305
  })
306
+
307
+ describe('build() does not validate', () => {
308
+ const validationSchema = typedObject(
309
+ 'app.bsky.test',
310
+ 'validation',
311
+ object({
312
+ actor: string({ format: 'did' }),
313
+ count: integer(),
314
+ }),
315
+ )
316
+
317
+ it('does not throw for invalid data', () => {
318
+ const result = validationSchema.build({
319
+ // @ts-expect-error
320
+ actor: 'not-a-did',
321
+ count: 123,
322
+ })
323
+
324
+ expect(result.$type).toBe('app.bsky.test#validation')
325
+ expect(result.actor).toBe('not-a-did')
326
+ expect(result.count).toBe(123)
327
+ })
328
+
329
+ it('does not throw for invalid types', () => {
330
+ const result = validationSchema.build({
331
+ actor: 'did:plc:abc123',
332
+ // @ts-expect-error
333
+ count: 'not-a-number',
334
+ })
335
+
336
+ expect(result.$type).toBe('app.bsky.test#validation')
337
+ expect(result.count).toBe('not-a-number')
338
+ })
339
+
340
+ it('does not throw for missing required fields', () => {
341
+ // @ts-expect-error
342
+ const result = validationSchema.build({})
343
+
344
+ expect(result.$type).toBe('app.bsky.test#validation')
345
+ expect(result.actor).toBeUndefined()
346
+ expect(result.count).toBeUndefined()
347
+ })
348
+
349
+ it('does not throw for extra fields', () => {
350
+ const result = validationSchema.build({
351
+ actor: 'did:plc:abc123',
352
+ count: 42,
353
+ // @ts-expect-error
354
+ extra: 'unexpected',
355
+ })
356
+
357
+ expect(result.$type).toBe('app.bsky.test#validation')
358
+ // @ts-expect-error
359
+ expect(result.extra).toBe('unexpected')
360
+ })
361
+
362
+ it('parse() still validates after build()', () => {
363
+ const built = validationSchema.build({
364
+ // @ts-expect-error
365
+ actor: 'not-a-did',
366
+ count: 123,
367
+ })
368
+
369
+ expect(() => validationSchema.parse(built)).toThrow('Invalid DID')
370
+ })
371
+
372
+ it('safeParse() can detect validation errors after build()', () => {
373
+ const built = validationSchema.build({
374
+ // @ts-expect-error
375
+ actor: 'not-a-did',
376
+ count: 123,
377
+ })
378
+
379
+ const result = validationSchema.safeParse(built)
380
+ expect(result.success).toBe(false)
381
+ })
382
+ })
306
383
  })
307
384
 
308
385
  describe('$build method', () => {
@@ -1,4 +1,4 @@
1
- import { isPlainObject } from '@atproto/lex-data'
1
+ import { LexMap, isPlainObject } from '@atproto/lex-data'
2
2
  import {
3
3
  $Type,
4
4
  $TypeOf,
@@ -43,7 +43,7 @@ export type MaybeTypedObject<
43
43
  */
44
44
  export class TypedObjectSchema<
45
45
  const TType extends $Type = $Type,
46
- const TShape extends Validator<{ [k: string]: unknown }> = any,
46
+ const TShape extends Validator<LexMap> = Validator<LexMap>,
47
47
  > extends Schema<
48
48
  $TypedMaybe<InferInput<TShape>, TType>,
49
49
  $TypedMaybe<InferOutput<TShape>, TType>
@@ -74,12 +74,13 @@ export class TypedObjectSchema<
74
74
  }
75
75
 
76
76
  build(
77
- input: Omit<InferInput<this>, '$type'>,
78
- ): $Typed<InferOutput<this>, TType> {
79
- return this.parse($typed(input, this.$type)) as $Typed<
80
- InferOutput<this>,
81
- TType
82
- >
77
+ input: Omit<InferOutput<TShape>, '$type'>,
78
+ ): $Typed<InferOutput<TShape>, TType>
79
+ build(
80
+ input: Omit<InferInput<TShape>, '$type'>,
81
+ ): $Typed<InferInput<TShape>, TType>
82
+ build(input: Record<string, unknown>) {
83
+ return $typed(input, this.$type)
83
84
  }
84
85
 
85
86
  isTypeOf<TValue extends Record<string, unknown>>(
@@ -155,7 +156,7 @@ export class TypedObjectSchema<
155
156
  export function typedObject<
156
157
  const N extends NsidString,
157
158
  const H extends string,
158
- const S extends Validator<{ [k: string]: unknown }>,
159
+ const S extends Validator<LexMap>,
159
160
  >(nsid: N, hash: H, validator: S): TypedObjectSchema<$Type<N, H>, S>
160
161
  export function typedObject<V extends { $type?: $Type }>(
161
162
  nsid: V extends { $type?: infer T extends string }
@@ -174,7 +175,7 @@ export function typedObject<V extends { $type?: $Type }>(
174
175
  export function typedObject<
175
176
  const N extends NsidString,
176
177
  const H extends string,
177
- const S extends Validator<{ [k: string]: unknown }>,
178
+ const S extends Validator<LexMap>,
178
179
  >(nsid: N, hash: H, validator: S) {
179
180
  return new TypedObjectSchema<$Type<N, H>, S>($type(nsid, hash), validator)
180
181
  }