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/demo/docs.json +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +3 -3
- package/dist/scripts/build.d.ts +8 -4
- package/dist/src/lang/runtime.d.ts +8 -4
- package/dist/src/types/Type.d.ts +5 -5
- package/dist/tjs-from-ts.js +20 -20
- package/dist/tjs-from-ts.js.map +3 -3
- package/dist/tjs-lang.js +51 -51
- package/dist/tjs-lang.js.map +3 -3
- package/package.json +1 -1
- package/src/lang/emitters/js.ts +4 -4
- package/src/lang/function-predicate.test.ts +8 -6
- package/src/lang/runtime.test.ts +58 -0
- package/src/lang/runtime.ts +27 -13
- package/src/types/Type.test.ts +68 -19
- package/src/types/Type.ts +80 -54
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tjs-lang",
|
|
3
|
-
"version": "0.7.
|
|
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",
|
package/src/lang/emitters/js.ts
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
16
|
-
expect(Callback.check('not a function')).toBe(
|
|
17
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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', () => {
|
package/src/lang/runtime.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/lang/runtime.ts
CHANGED
|
@@ -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
|
|
186
|
-
`Expected ${expected} for '${path}'
|
|
187
|
-
path,
|
|
188
|
-
|
|
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:
|
|
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
|
-
|
|
795
|
-
|
|
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 =
|
|
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 {
|
package/src/types/Type.test.ts
CHANGED
|
@@ -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(
|
|
211
|
-
expect(TString.check(null)).toBe(
|
|
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(
|
|
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(
|
|
226
|
-
expect(TBoolean.check('true')).toBe(
|
|
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(
|
|
234
|
-
expect(TInteger.check('1')).toBe(
|
|
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(
|
|
241
|
-
expect(TPositiveInt.check(-1)).toBe(
|
|
242
|
-
expect(TPositiveInt.check(1.5)).toBe(
|
|
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(
|
|
249
|
-
expect(TNonEmptyString.check(123)).toBe(
|
|
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(
|
|
256
|
-
expect(TEmail.check('no@domain')).toBe(
|
|
257
|
-
expect(TEmail.check('@example.com')).toBe(
|
|
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(
|
|
265
|
-
expect(TUrl.check('example.com')).toBe(
|
|
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(
|
|
272
|
-
expect(TUuid.check('550e8400-e29b-41d4-a716')).toBe(
|
|
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):
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
287
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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) =>
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
'
|
|
324
|
-
|
|
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
|
-
'
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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)
|
|
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
|
|
878
|
+
return `param '${expectedKeys[i]}' expected ${expectedKind}, got ${metaInfo.type.kind}`
|
|
853
879
|
}
|
|
854
880
|
}
|
|
855
881
|
}
|