tjs-lang 0.7.4 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tjs-lang",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "description": "Type-safe JavaScript dialect with runtime validation, sandboxed VM execution, and AI agent orchestration. Transpiles TypeScript to validated JS with fuel-metered execution for untrusted code.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -822,8 +822,8 @@ export function transpileToJS(
822
822
  // Core: MonadicError + typeError (needed by almost all validated functions)
823
823
  if (needsTypeError) {
824
824
  inlineParts.push(
825
- `class MonadicError extends Error{constructor(m,p,e,a,c){super(m);this.name='MonadicError';this.path=p;this.expected=e;this.actual=a;this.callStack=c}}`,
826
- `function typeError(p,e,v){const a=v===null?'null':typeof v;const err=new MonadicError('Expected '+e+" for '"+p+"', got "+a,p,e,a);const c=globalThis.__tjs?.getConfig?.();if(c?.logTypeErrors)console.error('[TJS TypeError] '+err.message);if(c?.throwTypeErrors)throw err;return err}`,
825
+ `class MonadicError extends Error{constructor(m,p,e,a,c,r){super(m);this.name='MonadicError';this.path=p;this.expected=e;this.actual=a;this.callStack=c;this.reason=r}}`,
826
+ `function typeError(p,e,v,r){const a=v===null?'null':typeof v;const m=r?'Expected '+e+" for '"+p+"': "+r:'Expected '+e+" for '"+p+"', got "+a;const err=new MonadicError(m,p,e,a,undefined,r);const c=globalThis.__tjs?.getConfig?.();if(c?.logTypeErrors)console.error('[TJS TypeError] '+err.message);if(c?.throwTypeErrors)throw err;return err}`,
827
827
  `function isMonadicError(v){return v instanceof Error&&v.name==='MonadicError'&&'path' in v}`
828
828
  )
829
829
  }
@@ -892,8 +892,8 @@ export function transpileToJS(
892
892
  // bang depends on typeError and isMonadicError — ensure they're inlined
893
893
  if (!needsTypeError) {
894
894
  inlineParts.push(
895
- `class MonadicError extends Error{constructor(m,p,e,a,c){super(m);this.name='MonadicError';this.path=p;this.expected=e;this.actual=a;this.callStack=c}}`,
896
- `function typeError(p,e,v){const a=v===null?'null':typeof v;const err=new MonadicError('Expected '+e+" for '"+p+"', got "+a,p,e,a);const c=globalThis.__tjs?.getConfig?.();if(c?.logTypeErrors)console.error('[TJS TypeError] '+err.message);if(c?.throwTypeErrors)throw err;return err}`,
895
+ `class MonadicError extends Error{constructor(m,p,e,a,c,r){super(m);this.name='MonadicError';this.path=p;this.expected=e;this.actual=a;this.callStack=c;this.reason=r}}`,
896
+ `function typeError(p,e,v,r){const a=v===null?'null':typeof v;const m=r?'Expected '+e+" for '"+p+"': "+r:'Expected '+e+" for '"+p+"', got "+a;const err=new MonadicError(m,p,e,a,undefined,r);const c=globalThis.__tjs?.getConfig?.();if(c?.logTypeErrors)console.error('[TJS TypeError] '+err.message);if(c?.throwTypeErrors)throw err;return err}`,
897
897
  `function isMonadicError(v){return v instanceof Error&&v.name==='MonadicError'&&'path' in v}`
898
898
  )
899
899
  }
@@ -12,9 +12,11 @@ describe('FunctionPredicate runtime', () => {
12
12
  })
13
13
  expect(Callback.check(() => {})).toBe(true)
14
14
  expect(Callback.check((x: number) => String(x))).toBe(true)
15
- expect(Callback.check(42)).toBe(false)
16
- expect(Callback.check('not a function')).toBe(false)
17
- expect(Callback.check(null)).toBe(false)
15
+ expect(Callback.check(42)).toBe('expected function, got number')
16
+ expect(Callback.check('not a function')).toBe(
17
+ 'expected function, got string'
18
+ )
19
+ expect(Callback.check(null)).toBe('expected function, got null')
18
20
  })
19
21
 
20
22
  it('should create from existing typed function', () => {
@@ -73,7 +75,7 @@ describe('FunctionPredicate runtime', () => {
73
75
  x: { type: { kind: 'integer' }, example: 0 },
74
76
  },
75
77
  }
76
- expect(Binop.check(wrongArity)).toBe(false)
78
+ expect(Binop.check(wrongArity)).toBe('expected 2 params, got 1')
77
79
  })
78
80
 
79
81
  it('should reject function with wrong param types via __tjs metadata', () => {
@@ -93,7 +95,7 @@ describe('FunctionPredicate runtime', () => {
93
95
  ;(badFn as any).__tjs = {
94
96
  params: { n: { type: { kind: 'integer' }, example: 0 } },
95
97
  }
96
- expect(StrFn.check(badFn)).toBe(false)
98
+ expect(StrFn.check(badFn)).toBe("param 'name' expected string, got integer")
97
99
  })
98
100
 
99
101
  it('should accept function with any-typed params', () => {
@@ -230,7 +232,7 @@ describe('Generic FunctionPredicate runtime', () => {
230
232
  expect(StringCreator.returns).toBe('')
231
233
  expect(StringCreator.params.x).toBe('')
232
234
  expect(StringCreator.check(() => {})).toBe(true)
233
- expect(StringCreator.check(42)).toBe(false)
235
+ expect(StringCreator.check(42)).toBe('expected function, got number')
234
236
  })
235
237
 
236
238
  it('should use default type arg when none provided', () => {
@@ -28,6 +28,9 @@ import {
28
28
  Is,
29
29
  IsNot,
30
30
  tjsEquals,
31
+ checkType,
32
+ MonadicError,
33
+ isMonadicError,
31
34
  } from './runtime'
32
35
  import { Eval, SafeFunction } from './eval'
33
36
 
@@ -1120,3 +1123,58 @@ function add(a: 1, b: 2): 3 {
1120
1123
  }
1121
1124
  })
1122
1125
  })
1126
+
1127
+ describe('predicate reason strings', () => {
1128
+ it('typeError includes reason in message and field', () => {
1129
+ const err = typeError('foo.bar', 'even number', 3, 'value is odd')
1130
+ expect(err.message).toContain('value is odd')
1131
+ expect(err.message).toContain('foo.bar')
1132
+ expect(err.reason).toBe('value is odd')
1133
+ expect(isMonadicError(err)).toBe(true)
1134
+ })
1135
+
1136
+ it('typeError without reason works as before', () => {
1137
+ const err = typeError('foo.bar', 'string', 42)
1138
+ expect(err.message).toContain('got number')
1139
+ expect(err.reason).toBeUndefined()
1140
+ })
1141
+
1142
+ it('checkType captures reason from predicate', () => {
1143
+ const EvenType = {
1144
+ check: (v: unknown): boolean | string => {
1145
+ if (typeof v !== 'number') return `expected number, got ${typeof v}`
1146
+ if (v % 2 !== 0) return `${v} is odd`
1147
+ return true
1148
+ },
1149
+ description: 'even number',
1150
+ }
1151
+
1152
+ // Pass
1153
+ expect(checkType(4, EvenType, 'test.val')).toBeNull()
1154
+
1155
+ // Fail with reason
1156
+ const err = checkType(3, EvenType, 'test.val')
1157
+ expect(err).not.toBeNull()
1158
+ expect(err!.message).toContain('3 is odd')
1159
+ expect(err!.reason).toBe('3 is odd')
1160
+
1161
+ // Fail with different reason
1162
+ const err2 = checkType('x', EvenType, 'test.val')
1163
+ expect(err2).not.toBeNull()
1164
+ expect(err2!.message).toContain('expected number, got string')
1165
+ })
1166
+
1167
+ it('checkType works with boolean-only predicates', () => {
1168
+ const PositiveType = {
1169
+ check: (v: unknown) => typeof v === 'number' && v > 0,
1170
+ description: 'positive number',
1171
+ }
1172
+
1173
+ expect(checkType(5, PositiveType, 'x')).toBeNull()
1174
+
1175
+ const err = checkType(-1, PositiveType, 'x')
1176
+ expect(err).not.toBeNull()
1177
+ expect(err!.message).toContain('positive number')
1178
+ expect(err!.reason).toBeUndefined()
1179
+ })
1180
+ })
@@ -142,13 +142,16 @@ export class MonadicError extends Error {
142
142
  readonly actual?: string
143
143
  /** TJS call stack (only in debug mode) - shows source locations */
144
144
  readonly callStack?: string[]
145
+ /** Why the check failed (from predicate reason strings) */
146
+ readonly reason?: string
145
147
 
146
148
  constructor(
147
149
  message: string,
148
150
  path: string,
149
151
  expected?: string,
150
152
  actual?: string,
151
- callStack?: string[]
153
+ callStack?: string[],
154
+ reason?: string
152
155
  ) {
153
156
  super(message)
154
157
  this.name = 'MonadicError'
@@ -156,6 +159,7 @@ export class MonadicError extends Error {
156
159
  this.expected = expected
157
160
  this.actual = actual
158
161
  this.callStack = callStack
162
+ this.reason = reason
159
163
  // Maintains proper stack trace in V8 environments
160
164
  if (Error.captureStackTrace) {
161
165
  Error.captureStackTrace(this, MonadicError)
@@ -177,18 +181,16 @@ export class MonadicError extends Error {
177
181
  export function typeError(
178
182
  path: string,
179
183
  expected: string,
180
- value: unknown
184
+ value: unknown,
185
+ reason?: string
181
186
  ): MonadicError {
182
187
  const actual = value === null ? 'null' : typeof value
183
188
  // Capture call stack in debug mode (getStack returns [] if not in debug mode)
184
189
  const stack = config.callStacks || config.debug ? getStack() : undefined
185
- const err = new MonadicError(
186
- `Expected ${expected} for '${path}', got ${actual}`,
187
- path,
188
- expected,
189
- actual,
190
- stack
191
- )
190
+ const msg = reason
191
+ ? `Expected ${expected} for '${path}': ${reason}`
192
+ : `Expected ${expected} for '${path}', got ${actual}`
193
+ const err = new MonadicError(msg, path, expected, actual, stack, reason)
192
194
 
193
195
  // Track in error history ring buffer (zero cost on happy path)
194
196
  if (config.trackErrors !== false) {
@@ -238,6 +240,8 @@ export interface TJSError {
238
240
  stack?: string[]
239
241
  expected?: string
240
242
  actual?: string
243
+ /** Why the check failed (from predicate reason strings) */
244
+ reason?: string
241
245
  cause?: Error | TJSError
242
246
  /** Source location for error reporting */
243
247
  loc?: { start: number; end: number }
@@ -779,7 +783,9 @@ export function isNativeType(value: unknown, typeName: string): boolean {
779
783
  */
780
784
  export function checkType(
781
785
  value: unknown,
782
- expected: string | { check: (v: unknown) => boolean; description: string },
786
+ expected:
787
+ | string
788
+ | { check: (v: unknown) => boolean | string; description: string },
783
789
  path?: string
784
790
  ): TJSError | null {
785
791
  // If value is already an error, propagate it
@@ -791,11 +797,17 @@ export function checkType(
791
797
  expected !== null &&
792
798
  'check' in expected
793
799
  ) {
794
- if (expected.check(value)) return null
795
- return error(`Expected ${expected.description} but got ${typeOf(value)}`, {
800
+ const result = expected.check(value)
801
+ if (result === true) return null
802
+ const reason = typeof result === 'string' ? result : undefined
803
+ const msg = reason
804
+ ? `Expected ${expected.description} for '${path}': ${reason}`
805
+ : `Expected ${expected.description} but got ${typeOf(value)}`
806
+ return error(msg, {
796
807
  path,
797
808
  expected: expected.description,
798
809
  actual: typeOf(value),
810
+ reason,
799
811
  })
800
812
  }
801
813
 
@@ -828,7 +840,9 @@ export function checkType(
828
840
  }
829
841
 
830
842
  /** Type specifier - either a string name or a RuntimeType */
831
- type TypeSpec = string | { check: (v: unknown) => boolean; description: string }
843
+ type TypeSpec =
844
+ | string
845
+ | { check: (v: unknown) => boolean | string; description: string }
832
846
 
833
847
  /** Parameter metadata with optional location */
834
848
  interface ParamMeta {
@@ -207,8 +207,8 @@ describe('Built-in Types', () => {
207
207
  it('TString validates strings', () => {
208
208
  expect(TString.check('hello')).toBe(true)
209
209
  expect(TString.check('')).toBe(true)
210
- expect(TString.check(123)).toBe(false)
211
- expect(TString.check(null)).toBe(false)
210
+ expect(TString.check(123)).toBe('expected string, got number')
211
+ expect(TString.check(null)).toBe('expected string, got null')
212
212
  })
213
213
 
214
214
  it('TNumber validates numbers', () => {
@@ -216,60 +216,64 @@ describe('Built-in Types', () => {
216
216
  expect(TNumber.check(0)).toBe(true)
217
217
  expect(TNumber.check(-1.5)).toBe(true)
218
218
  expect(TNumber.check(NaN)).toBe(true) // NaN is typeof number
219
- expect(TNumber.check('123')).toBe(false)
219
+ expect(TNumber.check('123')).toBe('expected number, got string')
220
220
  })
221
221
 
222
222
  it('TBoolean validates booleans', () => {
223
223
  expect(TBoolean.check(true)).toBe(true)
224
224
  expect(TBoolean.check(false)).toBe(true)
225
- expect(TBoolean.check(1)).toBe(false)
226
- expect(TBoolean.check('true')).toBe(false)
225
+ expect(TBoolean.check(1)).toBe('expected boolean, got number')
226
+ expect(TBoolean.check('true')).toBe('expected boolean, got string')
227
227
  })
228
228
 
229
229
  it('TInteger validates integers', () => {
230
230
  expect(TInteger.check(1)).toBe(true)
231
231
  expect(TInteger.check(0)).toBe(true)
232
232
  expect(TInteger.check(-5)).toBe(true)
233
- expect(TInteger.check(1.5)).toBe(false)
234
- expect(TInteger.check('1')).toBe(false)
233
+ expect(TInteger.check(1.5)).toBe('1.5 is not an integer')
234
+ expect(TInteger.check('1')).toBe('expected integer, got string')
235
235
  })
236
236
 
237
237
  it('TPositiveInt validates positive integers', () => {
238
238
  expect(TPositiveInt.check(1)).toBe(true)
239
239
  expect(TPositiveInt.check(100)).toBe(true)
240
- expect(TPositiveInt.check(0)).toBe(false)
241
- expect(TPositiveInt.check(-1)).toBe(false)
242
- expect(TPositiveInt.check(1.5)).toBe(false)
240
+ expect(TPositiveInt.check(0)).toBe('0 is not positive')
241
+ expect(TPositiveInt.check(-1)).toBe('-1 is not positive')
242
+ expect(TPositiveInt.check(1.5)).toBe('1.5 is not an integer')
243
243
  })
244
244
 
245
245
  it('TNonEmptyString validates non-empty strings', () => {
246
246
  expect(TNonEmptyString.check('hello')).toBe(true)
247
247
  expect(TNonEmptyString.check('a')).toBe(true)
248
- expect(TNonEmptyString.check('')).toBe(false)
249
- expect(TNonEmptyString.check(123)).toBe(false)
248
+ expect(TNonEmptyString.check('')).toBe('string is empty')
249
+ expect(TNonEmptyString.check(123)).toBe('expected string, got number')
250
250
  })
251
251
 
252
252
  it('TEmail validates email addresses', () => {
253
253
  expect(TEmail.check('user@example.com')).toBe(true)
254
254
  expect(TEmail.check('a@b.c')).toBe(true)
255
- expect(TEmail.check('invalid')).toBe(false)
256
- expect(TEmail.check('no@domain')).toBe(false)
257
- expect(TEmail.check('@example.com')).toBe(false)
255
+ expect(TEmail.check('invalid')).toBe('"invalid" is not a valid email')
256
+ expect(TEmail.check('no@domain')).toBe('"no@domain" is not a valid email')
257
+ expect(TEmail.check('@example.com')).toBe(
258
+ '"@example.com" is not a valid email'
259
+ )
258
260
  })
259
261
 
260
262
  it('TUrl validates URLs', () => {
261
263
  expect(TUrl.check('https://example.com')).toBe(true)
262
264
  expect(TUrl.check('http://localhost:3000')).toBe(true)
263
265
  expect(TUrl.check('ftp://files.example.com')).toBe(true)
264
- expect(TUrl.check('not-a-url')).toBe(false)
265
- expect(TUrl.check('example.com')).toBe(false)
266
+ expect(TUrl.check('not-a-url')).toBe('"not-a-url" is not a valid URL')
267
+ expect(TUrl.check('example.com')).toBe('"example.com" is not a valid URL')
266
268
  })
267
269
 
268
270
  it('TUuid validates UUIDs', () => {
269
271
  expect(TUuid.check('550e8400-e29b-41d4-a716-446655440000')).toBe(true)
270
272
  expect(TUuid.check('550E8400-E29B-41D4-A716-446655440000')).toBe(true)
271
- expect(TUuid.check('not-a-uuid')).toBe(false)
272
- expect(TUuid.check('550e8400-e29b-41d4-a716')).toBe(false)
273
+ expect(TUuid.check('not-a-uuid')).toBe('"not-a-uuid" is not a valid UUID')
274
+ expect(TUuid.check('550e8400-e29b-41d4-a716')).toBe(
275
+ '"550e8400-e29b-41d4-a716" is not a valid UUID'
276
+ )
273
277
  })
274
278
  })
275
279
 
@@ -676,3 +680,48 @@ describe('Enum', () => {
676
680
  expect(HttpStatus.members.NotFound).toBe(404)
677
681
  })
678
682
  })
683
+
684
+ describe('predicate reason strings', () => {
685
+ it('Type() passes through reason strings from predicates', () => {
686
+ const EvenNumber = Type<number>('even number', (v: unknown) => {
687
+ if (typeof v !== 'number') return 'not a number'
688
+ if (v % 2 !== 0) return `${v} is odd`
689
+ return true
690
+ })
691
+
692
+ expect(EvenNumber.check(4)).toBe(true)
693
+ expect(EvenNumber.check(3)).toBe('3 is odd')
694
+ expect(EvenNumber.check('x')).toBe('not a number')
695
+ })
696
+
697
+ it('schema-based types still return boolean', () => {
698
+ const StringType = Type('a string', s.string)
699
+ expect(StringType.check('hello')).toBe(true)
700
+ expect(StringType.check(42)).toBe(false)
701
+ })
702
+
703
+ it('Nullable correctly handles reason-returning predicates', () => {
704
+ const EvenNumber = Type<number>('even number', (v: unknown) => {
705
+ if (typeof v !== 'number') return 'not a number'
706
+ if (v % 2 !== 0) return `${v} is odd`
707
+ return true
708
+ })
709
+ const NullableEven = Nullable(EvenNumber)
710
+
711
+ expect(NullableEven.check(null)).toBe(true)
712
+ expect(NullableEven.check(4)).toBe(true)
713
+ expect(NullableEven.check(3)).toBe(false) // reason lost in combinator, but correctly fails
714
+ })
715
+
716
+ it('TArray correctly handles reason-returning predicates', () => {
717
+ const Positive = Type<number>('positive', (v: unknown) => {
718
+ if (typeof v !== 'number') return 'not a number'
719
+ if (v <= 0) return `${v} is not positive`
720
+ return true
721
+ })
722
+ const PositiveArray = TArray(Positive)
723
+
724
+ expect(PositiveArray.check([1, 2, 3])).toBe(true)
725
+ expect(PositiveArray.check([1, -1, 3])).toBe(false)
726
+ })
727
+ })
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
  }