tjs-lang 0.7.3 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/types/Type.ts CHANGED
@@ -35,12 +35,12 @@ type Schema = Base<any> | JSONSchema
35
35
  export interface RuntimeType<T = unknown> {
36
36
  /** Human-readable description of the type */
37
37
  readonly description: string
38
- /** Check if a value matches this type */
39
- check(value: unknown): value is T
38
+ /** Check if a value matches this type. Returns true on pass, false on fail, or a reason string on fail. */
39
+ check(value: unknown): boolean | string
40
40
  /** The underlying schema (if schema-based) */
41
41
  readonly schema?: Schema
42
- /** The predicate function (if predicate-based) */
43
- readonly predicate?: (value: unknown) => boolean
42
+ /** The predicate function (if predicate-based). May return a reason string on failure. */
43
+ readonly predicate?: (value: unknown) => boolean | string
44
44
  /** Example value (for documentation and signature testing) */
45
45
  readonly example?: T
46
46
  /** Multiple example values (from schema metadata, for autocomplete hints) */
@@ -99,7 +99,7 @@ function isJSONSchema(value: unknown): value is JSONSchema {
99
99
  export function Type<T = unknown>(
100
100
  descriptionOrSchema: string | Schema,
101
101
  predicateOrSchemaOrExample?:
102
- | ((value: unknown) => boolean)
102
+ | ((value: unknown) => boolean | string)
103
103
  | Schema
104
104
  | T
105
105
  | undefined,
@@ -108,7 +108,7 @@ export function Type<T = unknown>(
108
108
  ): RuntimeType<T> {
109
109
  // Parse arguments
110
110
  let description: string
111
- let predicate: ((value: unknown) => boolean) | undefined
111
+ let predicate: ((value: unknown) => boolean | string) | undefined
112
112
  let schema: Schema | undefined
113
113
  let example: T | undefined = exampleArg
114
114
  let defaultValue: T | undefined = defaultArg
@@ -119,7 +119,9 @@ export function Type<T = unknown>(
119
119
 
120
120
  if (typeof predicateOrSchemaOrExample === 'function') {
121
121
  // Type(description, predicate, example?, default?)
122
- predicate = predicateOrSchemaOrExample as (value: unknown) => boolean
122
+ predicate = predicateOrSchemaOrExample as (
123
+ value: unknown
124
+ ) => boolean | string
123
125
  // If we have example, infer schema from it for the type guard in predicate
124
126
  if (example !== undefined) {
125
127
  schema = s.infer(example)
@@ -176,7 +178,8 @@ export function Type<T = unknown>(
176
178
  }
177
179
 
178
180
  // Build the check function
179
- const check = (value: unknown): value is T => {
181
+ // Returns true on pass, false on fail, or a reason string on fail
182
+ const check = (value: unknown): boolean | string => {
180
183
  if (predicate) {
181
184
  return predicate(value)
182
185
  }
@@ -264,46 +267,59 @@ function schemaToDescription(schema: Schema): string {
264
267
  // ============================================================================
265
268
 
266
269
  /** String type */
267
- export const TString = Type<string>(
268
- 'string',
269
- (v: unknown) => typeof v === 'string'
270
- )
270
+ export const TString = Type<string>('string', (v: unknown) => {
271
+ if (typeof v === 'string') return true
272
+ return `expected string, got ${v === null ? 'null' : typeof v}`
273
+ })
271
274
 
272
275
  /** Number type */
273
- export const TNumber = Type<number>(
274
- 'number',
275
- (v: unknown) => typeof v === 'number'
276
- )
276
+ export const TNumber = Type<number>('number', (v: unknown) => {
277
+ if (typeof v === 'number') return true
278
+ return `expected number, got ${v === null ? 'null' : typeof v}`
279
+ })
277
280
 
278
281
  /** Boolean type */
279
- export const TBoolean = Type<boolean>(
280
- 'boolean',
281
- (v: unknown) => typeof v === 'boolean'
282
- )
282
+ export const TBoolean = Type<boolean>('boolean', (v: unknown) => {
283
+ if (typeof v === 'boolean') return true
284
+ return `expected boolean, got ${v === null ? 'null' : typeof v}`
285
+ })
283
286
 
284
287
  /** Integer type */
285
- export const TInteger = Type<number>(
286
- 'integer',
287
- (v: unknown) => typeof v === 'number' && Number.isInteger(v)
288
- )
288
+ export const TInteger = Type<number>('integer', (v: unknown) => {
289
+ if (typeof v !== 'number')
290
+ return `expected integer, got ${v === null ? 'null' : typeof v}`
291
+ if (!Number.isInteger(v)) return `${v} is not an integer`
292
+ return true
293
+ })
289
294
 
290
295
  /** Positive integer type */
291
- export const TPositiveInt = Type<number>(
292
- 'positive integer',
293
- (v: unknown) => typeof v === 'number' && Number.isInteger(v) && v > 0
294
- )
296
+ export const TPositiveInt = Type<number>('positive integer', (v: unknown) => {
297
+ if (typeof v !== 'number')
298
+ return `expected positive integer, got ${v === null ? 'null' : typeof v}`
299
+ if (!Number.isInteger(v)) return `${v} is not an integer`
300
+ if (v <= 0) return `${v} is not positive`
301
+ return true
302
+ })
295
303
 
296
304
  /** Non-empty string type */
297
305
  export const TNonEmptyString = Type<string>(
298
306
  'non-empty string',
299
- (v: unknown) => typeof v === 'string' && v.length > 0
307
+ (v: unknown) => {
308
+ if (typeof v !== 'string')
309
+ return `expected string, got ${v === null ? 'null' : typeof v}`
310
+ if (v.length === 0) return 'string is empty'
311
+ return true
312
+ }
300
313
  )
301
314
 
302
315
  /** Email type (basic validation) */
303
- export const TEmail = Type<string>(
304
- 'email address',
305
- (v: unknown) => typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)
306
- )
316
+ export const TEmail = Type<string>('email address', (v: unknown) => {
317
+ if (typeof v !== 'string')
318
+ return `expected string, got ${v === null ? 'null' : typeof v}`
319
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v))
320
+ return `"${v}" is not a valid email`
321
+ return true
322
+ })
307
323
 
308
324
  /**
309
325
  * Check if a string is a valid URL (portable helper for predicates)
@@ -319,18 +335,23 @@ export const isValidUrl = (v: string): boolean => {
319
335
  }
320
336
 
321
337
  /** URL type */
322
- export const TUrl = Type<string>(
323
- 'URL',
324
- (v: unknown) => typeof v === 'string' && isValidUrl(v)
325
- )
338
+ export const TUrl = Type<string>('URL', (v: unknown) => {
339
+ if (typeof v !== 'string')
340
+ return `expected string, got ${v === null ? 'null' : typeof v}`
341
+ if (!isValidUrl(v)) return `"${v}" is not a valid URL`
342
+ return true
343
+ })
326
344
 
327
345
  /** UUID type */
328
- export const TUuid = Type<string>(
329
- 'UUID',
330
- (v: unknown) =>
331
- typeof v === 'string' &&
332
- /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v)
333
- )
346
+ export const TUuid = Type<string>('UUID', (v: unknown) => {
347
+ if (typeof v !== 'string')
348
+ return `expected string, got ${v === null ? 'null' : typeof v}`
349
+ if (
350
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v)
351
+ )
352
+ return `"${v}" is not a valid UUID`
353
+ return true
354
+ })
334
355
 
335
356
  /**
336
357
  * Check if a string is a valid ISO 8601 timestamp (portable helper for predicates)
@@ -371,7 +392,7 @@ export const LegalDate = Type<string>(
371
392
  export function Nullable<T>(type: RuntimeType<T>): RuntimeType<T | null> {
372
393
  return Type<T | null>(
373
394
  `${type.description} or null`,
374
- (v: unknown) => v === null || type.check(v)
395
+ (v: unknown) => v === null || type.check(v) === true
375
396
  )
376
397
  }
377
398
 
@@ -381,7 +402,7 @@ export function Optional<T>(
381
402
  ): RuntimeType<T | null | undefined> {
382
403
  return Type<T | null | undefined>(
383
404
  `${type.description} (optional)`,
384
- (v: unknown) => v === null || v === undefined || type.check(v)
405
+ (v: unknown) => v === null || v === undefined || type.check(v) === true
385
406
  )
386
407
  }
387
408
 
@@ -434,14 +455,17 @@ export function Union<T extends unknown[]>(
434
455
  types.push(...restTypes)
435
456
 
436
457
  const description = types.map((t) => t.description).join(' | ')
437
- return Type(description, (v: unknown) => types.some((t) => t.check(v)))
458
+ return Type(description, (v: unknown) =>
459
+ types.some((t) => t.check(v) === true)
460
+ )
438
461
  }
439
462
 
440
463
  /** Create an array type */
441
464
  export function TArray<T>(itemType: RuntimeType<T>): RuntimeType<T[]> {
442
465
  return Type<T[]>(
443
466
  `array of ${itemType.description}`,
444
- (v: unknown) => Array.isArray(v) && v.every((item) => itemType.check(item))
467
+ (v: unknown) =>
468
+ Array.isArray(v) && v.every((item) => itemType.check(item) === true)
445
469
  )
446
470
  }
447
471
 
@@ -467,7 +491,7 @@ export interface GenericType<TParams extends string[] = string[]> {
467
491
  */
468
492
  function typeParamToCheck(param: TypeParam): (value: unknown) => boolean {
469
493
  if (isRuntimeType(param)) {
470
- return (v) => param.check(v)
494
+ return (v) => param.check(v) === true
471
495
  }
472
496
  // Check if it's a schema builder (has .schema property)
473
497
  if (param && typeof param === 'object' && 'schema' in param) {
@@ -820,21 +844,23 @@ function _createFunctionPredicate(
820
844
  returnContract,
821
845
  toJSONSchema: () => ({ description: name, type: 'function' as any }),
822
846
  strip: (value: unknown) => value,
823
- // eslint-disable-next-line @typescript-eslint/ban-types
824
- check: (value: unknown): value is Function => {
825
- if (typeof value !== 'function') return false
847
+ check: (value: unknown): boolean | string => {
848
+ if (typeof value !== 'function')
849
+ return `expected function, got ${
850
+ value === null ? 'null' : typeof value
851
+ }`
826
852
 
827
853
  // Structural validation: check arity and __tjs metadata
828
854
  const expectedArity = Object.keys(params).length
829
855
  if (expectedArity > 0) {
830
- // Check function.length (number of params before first default)
831
856
  // eslint-disable-next-line @typescript-eslint/ban-types
832
857
  const fn = value as Function
833
858
  const meta = (fn as any).__tjs
834
859
  if (meta?.params) {
835
860
  // Has TJS metadata — check param count matches
836
861
  const metaParamCount = Object.keys(meta.params).length
837
- if (metaParamCount !== expectedArity) return false
862
+ if (metaParamCount !== expectedArity)
863
+ return `expected ${expectedArity} params, got ${metaParamCount}`
838
864
 
839
865
  // Check param type kinds match where both sides have type info
840
866
  const expectedKeys = Object.keys(params)
@@ -849,7 +875,7 @@ function _createFunctionPredicate(
849
875
  metaInfo.type.kind !== expectedKind &&
850
876
  metaInfo.type.kind !== 'any'
851
877
  )
852
- return false
878
+ return `param '${expectedKeys[i]}' expected ${expectedKind}, got ${metaInfo.type.kind}`
853
879
  }
854
880
  }
855
881
  }