tjs-lang 0.5.3 → 0.5.4
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/CLAUDE.md +1 -1
- package/demo/docs.json +6 -6
- package/demo/src/demo-nav.ts +51 -143
- package/demo/src/index.ts +35 -68
- package/demo/src/ts-playground.ts +18 -8
- package/dist/index.js +126 -112
- package/dist/index.js.map +11 -11
- package/dist/src/lang/emitters/js.d.ts +2 -2
- package/dist/src/lang/inference.d.ts +2 -2
- package/dist/src/test-examples.d.ts +41 -0
- package/dist/tjs-full.js +126 -112
- package/dist/tjs-full.js.map +11 -11
- package/dist/tjs-transpiler.js +102 -88
- package/dist/tjs-transpiler.js.map +8 -8
- package/dist/tjs-vm.js +18 -18
- package/dist/tjs-vm.js.map +5 -5
- package/package.json +1 -1
- package/src/lang/codegen.test.ts +69 -17
- package/src/lang/emitters/from-ts.ts +7 -32
- package/src/lang/emitters/js.ts +97 -7
- package/src/lang/from-ts.test.ts +3 -3
- package/src/lang/inference.ts +34 -20
- package/src/lang/parser.test.ts +4 -4
- package/src/lang/transpiler.test.ts +5 -3
- package/src/lang/typescript-syntax.test.ts +20 -17
- package/src/test-examples.test.ts +4 -12
- package/src/test-examples.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tjs-lang",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
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/codegen.test.ts
CHANGED
|
@@ -28,11 +28,11 @@ describe('TS → TJS conversion quality', () => {
|
|
|
28
28
|
expect(code).not.toContain('x: number')
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
-
it('converts optional param to
|
|
31
|
+
it('converts optional param to union with undefined', () => {
|
|
32
32
|
const ts = `function greet(name?: string): string { return name || 'World' }`
|
|
33
33
|
const { code } = fromTS(ts, { emitTJS: true })
|
|
34
34
|
|
|
35
|
-
expect(code).toContain("name
|
|
35
|
+
expect(code).toContain("name: '' | undefined")
|
|
36
36
|
expect(code).not.toContain('name?')
|
|
37
37
|
})
|
|
38
38
|
|
|
@@ -50,12 +50,11 @@ describe('TS → TJS conversion quality', () => {
|
|
|
50
50
|
expect(code).toContain('flag: false')
|
|
51
51
|
})
|
|
52
52
|
|
|
53
|
-
it('converts optional boolean param to
|
|
53
|
+
it('converts optional boolean param to union with undefined', () => {
|
|
54
54
|
const ts = `function greet(name: string, excited?: boolean): string { return excited ? name + '!' : name }`
|
|
55
55
|
const { code } = fromTS(ts, { emitTJS: true })
|
|
56
56
|
|
|
57
|
-
expect(code).toContain('excited
|
|
58
|
-
expect(code).not.toContain('excited = true')
|
|
57
|
+
expect(code).toContain('excited: false | undefined')
|
|
59
58
|
})
|
|
60
59
|
|
|
61
60
|
it('converts array param correctly', () => {
|
|
@@ -86,7 +85,7 @@ describe('TS → TJS conversion quality', () => {
|
|
|
86
85
|
const { code } = fromTS(ts, { emitTJS: true })
|
|
87
86
|
|
|
88
87
|
expect(code).toContain("url: ''")
|
|
89
|
-
expect(code).toContain('timeout
|
|
88
|
+
expect(code).toContain('timeout: 0.0 | undefined')
|
|
90
89
|
})
|
|
91
90
|
})
|
|
92
91
|
|
|
@@ -296,18 +295,18 @@ class Api {
|
|
|
296
295
|
})
|
|
297
296
|
|
|
298
297
|
describe('nullable types', () => {
|
|
299
|
-
it('converts T | null to T
|
|
298
|
+
it('converts T | null to T | null', () => {
|
|
300
299
|
const ts = `function maybe(x: string | null): string | null { return x }`
|
|
301
300
|
const { code } = fromTS(ts, { emitTJS: true })
|
|
302
301
|
|
|
303
|
-
expect(code).toContain("''
|
|
302
|
+
expect(code).toContain("'' | null")
|
|
304
303
|
})
|
|
305
304
|
|
|
306
|
-
it('converts T | undefined to T
|
|
305
|
+
it('converts T | undefined to T | undefined', () => {
|
|
307
306
|
const ts = `function maybe(x: number | undefined): number | undefined { return x }`
|
|
308
307
|
const { code } = fromTS(ts, { emitTJS: true })
|
|
309
308
|
|
|
310
|
-
expect(code).toContain('0
|
|
309
|
+
expect(code).toContain('0 | undefined')
|
|
311
310
|
})
|
|
312
311
|
})
|
|
313
312
|
|
|
@@ -379,12 +378,21 @@ class Api {
|
|
|
379
378
|
|
|
380
379
|
describe('TJS → JS transpilation quality', () => {
|
|
381
380
|
describe('colon syntax transformation', () => {
|
|
382
|
-
it('
|
|
381
|
+
it('strips colon type annotations from output (required params get no default)', () => {
|
|
383
382
|
const source = `function greet(name: 'World') { return name }`
|
|
384
383
|
const { code } = tjs(source)
|
|
385
384
|
|
|
386
|
-
|
|
385
|
+
// Required params (`:` syntax) should have no default in JS
|
|
386
|
+
expect(code).toContain('function greet(name)')
|
|
387
387
|
expect(code).not.toContain("name: 'World'")
|
|
388
|
+
expect(code).not.toContain('name = ')
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('preserves defaults for optional params (= syntax)', () => {
|
|
392
|
+
const source = `function greet(name = 'World') { return name }`
|
|
393
|
+
const { code } = tjs(source)
|
|
394
|
+
|
|
395
|
+
expect(code).toContain("name = 'World'")
|
|
388
396
|
})
|
|
389
397
|
})
|
|
390
398
|
|
|
@@ -632,12 +640,12 @@ console.log(second())
|
|
|
632
640
|
expect(code).not.toContain('y: string')
|
|
633
641
|
})
|
|
634
642
|
|
|
635
|
-
it('uses
|
|
643
|
+
it('uses union with undefined for optional params', () => {
|
|
636
644
|
const ts = `function test(x?: number, y?: string): void { }`
|
|
637
645
|
const { code } = fromTS(ts, { emitTJS: true })
|
|
638
646
|
|
|
639
|
-
expect(code).toContain('x
|
|
640
|
-
expect(code).toContain("y
|
|
647
|
+
expect(code).toContain('x: 0.0 | undefined')
|
|
648
|
+
expect(code).toContain("y: '' | undefined")
|
|
641
649
|
})
|
|
642
650
|
|
|
643
651
|
it('uses -! syntax for return types (skip signature test)', () => {
|
|
@@ -1255,7 +1263,9 @@ function test(required: string, optional?: number): void { }
|
|
|
1255
1263
|
const { types } = tjs(tjsCode)
|
|
1256
1264
|
|
|
1257
1265
|
expect(types?.test?.params?.required?.required).toBe(true)
|
|
1258
|
-
|
|
1266
|
+
// TS optional becomes TJS union with undefined (required param that accepts undefined)
|
|
1267
|
+
expect(types?.test?.params?.optional?.required).toBe(true)
|
|
1268
|
+
expect(types?.test?.params?.optional?.type?.kind).toBe('union')
|
|
1259
1269
|
})
|
|
1260
1270
|
})
|
|
1261
1271
|
})
|
|
@@ -1788,7 +1798,7 @@ function format(x: '') -! '' { return x + '!' }
|
|
|
1788
1798
|
})
|
|
1789
1799
|
|
|
1790
1800
|
it('error short-circuits function body', () => {
|
|
1791
|
-
|
|
1801
|
+
const bodyExecuted = false
|
|
1792
1802
|
|
|
1793
1803
|
const { code } = tjs(`
|
|
1794
1804
|
function process(x: '') -! '' {
|
|
@@ -1935,4 +1945,46 @@ function divide(a: 10, b: 2) -? { value: 0, error = '' } {
|
|
|
1935
1945
|
expect(result.code).toContain('"error"')
|
|
1936
1946
|
})
|
|
1937
1947
|
})
|
|
1948
|
+
|
|
1949
|
+
describe('union param JS output', () => {
|
|
1950
|
+
it('required union param has no default in JS', () => {
|
|
1951
|
+
const result = tjs('function f(x: false | undefined) { return x }')
|
|
1952
|
+
expect(result.code).toContain('function f(x)')
|
|
1953
|
+
expect(result.code).not.toMatch(/x = false/)
|
|
1954
|
+
expect(result.code).not.toMatch(/x = false \| undefined/)
|
|
1955
|
+
})
|
|
1956
|
+
|
|
1957
|
+
it('optional union param keeps first value as default', () => {
|
|
1958
|
+
const result = tjs('function f(x = false | undefined) { return x }')
|
|
1959
|
+
expect(result.code).toContain('x = false')
|
|
1960
|
+
expect(result.code).not.toMatch(/x = false \| undefined/)
|
|
1961
|
+
})
|
|
1962
|
+
|
|
1963
|
+
it('generates union type check for required union param', () => {
|
|
1964
|
+
const result = tjs('function f(x: false | undefined) { return x }')
|
|
1965
|
+
expect(result.code).toContain("typeof x !== 'boolean'")
|
|
1966
|
+
expect(result.code).toContain('x !== undefined')
|
|
1967
|
+
})
|
|
1968
|
+
|
|
1969
|
+
it('generates nullable integer check for int|null', () => {
|
|
1970
|
+
const result = tjs('function f(x: 0 | null) { return x }')
|
|
1971
|
+
expect(result.code).toContain('function f(x)')
|
|
1972
|
+
// 0 | null is parsed as { kind: 'integer', nullable: true }, not a union
|
|
1973
|
+
expect(result.code).toContain("'integer'")
|
|
1974
|
+
// Nullable types skip the check when value is null
|
|
1975
|
+
expect(result.metadata?.f?.params?.x?.type?.nullable).toBe(true)
|
|
1976
|
+
})
|
|
1977
|
+
|
|
1978
|
+
it('preserves | in function body (bitwise OR)', () => {
|
|
1979
|
+
const result = tjs('function f(x: 0) { return x | 0xFF }')
|
|
1980
|
+
expect(result.code).toContain('x | 0xFF')
|
|
1981
|
+
})
|
|
1982
|
+
|
|
1983
|
+
it('preserves union metadata despite stripping from JS', () => {
|
|
1984
|
+
const result = tjs('function f(x: false | undefined) { return x }')
|
|
1985
|
+
const meta = result.metadata?.f
|
|
1986
|
+
expect(meta).toBeDefined()
|
|
1987
|
+
expect(meta.params.x.type.kind).toBe('union')
|
|
1988
|
+
})
|
|
1989
|
+
})
|
|
1938
1990
|
})
|
|
@@ -139,11 +139,6 @@ function typeToExample(
|
|
|
139
139
|
case ts.SyntaxKind.NumberKeyword:
|
|
140
140
|
return '0.0'
|
|
141
141
|
case ts.SyntaxKind.BooleanKeyword:
|
|
142
|
-
// REVISIT: TS `x?: boolean` becomes TJS `x = false`, which collapses
|
|
143
|
-
// "not passed" (undefined) and "passed as false" into the same value.
|
|
144
|
-
// Code that distinguishes the three states (true/false/undefined) will
|
|
145
|
-
// break. Consider emitting `x: false || null` for optional booleans
|
|
146
|
-
// to preserve the undefined state.
|
|
147
142
|
return 'false'
|
|
148
143
|
case ts.SyntaxKind.NullKeyword:
|
|
149
144
|
return 'null'
|
|
@@ -301,10 +296,10 @@ function typeToExample(
|
|
|
301
296
|
const hasUndefined = unionType.types.some(isUndefinedType)
|
|
302
297
|
|
|
303
298
|
if (nonNullTypes.length === 1 && (hasNull || hasUndefined)) {
|
|
304
|
-
// Nullable type: T | null -> T
|
|
299
|
+
// Nullable type: T | null -> T | null
|
|
305
300
|
const baseExample = typeToExample(nonNullTypes[0], checker)
|
|
306
|
-
if (hasNull) return `${baseExample}
|
|
307
|
-
if (hasUndefined) return `${baseExample}
|
|
301
|
+
if (hasNull) return `${baseExample} | null`
|
|
302
|
+
if (hasUndefined) return `${baseExample} | undefined`
|
|
308
303
|
}
|
|
309
304
|
|
|
310
305
|
// General union: use first type as example
|
|
@@ -921,7 +916,7 @@ function transformFunctionToTJS(
|
|
|
921
916
|
warnings?: string[],
|
|
922
917
|
includeLineNumber?: boolean
|
|
923
918
|
): string {
|
|
924
|
-
const params
|
|
919
|
+
const params = transformParams(node.parameters, sourceFile, warnings)
|
|
925
920
|
|
|
926
921
|
// Get line number (1-indexed) for source mapping
|
|
927
922
|
const { line } = sourceFile.getLineAndCharacterOfPosition(
|
|
@@ -929,27 +924,6 @@ function transformFunctionToTJS(
|
|
|
929
924
|
)
|
|
930
925
|
const lineComment = includeLineNumber ? `/* line ${line + 1} */\n` : ''
|
|
931
926
|
|
|
932
|
-
for (const param of node.parameters) {
|
|
933
|
-
const name = param.name.getText(sourceFile)
|
|
934
|
-
const isOptional = !!param.questionToken || !!param.initializer
|
|
935
|
-
const typeExample = typeToExample(param.type, undefined, warnings)
|
|
936
|
-
|
|
937
|
-
if (param.initializer) {
|
|
938
|
-
// Has default value - use it directly
|
|
939
|
-
const defaultText = param.initializer.getText(sourceFile)
|
|
940
|
-
params.push(`${name} = ${defaultText}`)
|
|
941
|
-
} else if (typeExample === 'any' || typeExample === 'undefined') {
|
|
942
|
-
// any/undefined type - no annotation in TJS (bare name means any)
|
|
943
|
-
params.push(name)
|
|
944
|
-
} else if (isOptional) {
|
|
945
|
-
// Optional without default - use = for optional
|
|
946
|
-
params.push(`${name} = ${typeExample}`)
|
|
947
|
-
} else {
|
|
948
|
-
// Required - use : for required
|
|
949
|
-
params.push(`${name}: ${typeExample}`)
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
|
|
953
927
|
const funcName =
|
|
954
928
|
explicitName ||
|
|
955
929
|
(ts.isFunctionDeclaration(node) && node.name
|
|
@@ -1208,8 +1182,9 @@ function transformParams(
|
|
|
1208
1182
|
// any/undefined type - no annotation in TJS (bare name means any)
|
|
1209
1183
|
params.push(name)
|
|
1210
1184
|
} else if (isOptional) {
|
|
1211
|
-
// Optional without default - use
|
|
1212
|
-
|
|
1185
|
+
// Optional without default - use union with undefined to preserve
|
|
1186
|
+
// three-state semantics (e.g. TS `flag?: boolean` can be true/false/undefined)
|
|
1187
|
+
params.push(`${name}: ${typeExample} | undefined`)
|
|
1213
1188
|
} else {
|
|
1214
1189
|
// Required - use : for required
|
|
1215
1190
|
params.push(`${name}: ${typeExample}`)
|
package/src/lang/emitters/js.ts
CHANGED
|
@@ -139,6 +139,26 @@ export interface TJSTypeInfo {
|
|
|
139
139
|
destructuredRequired?: Set<string>
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Check if a param used `:` (required) or `=` (optional) in the raw source.
|
|
144
|
+
* Finds the function's param list by name, then looks for `paramName:` vs `paramName =`.
|
|
145
|
+
*/
|
|
146
|
+
function isParamRequiredInSource(
|
|
147
|
+
source: string,
|
|
148
|
+
funcName: string,
|
|
149
|
+
paramName: string
|
|
150
|
+
): boolean {
|
|
151
|
+
if (!source || !funcName) return false
|
|
152
|
+
// Find the function declaration and its param list
|
|
153
|
+
const funcPattern = new RegExp(
|
|
154
|
+
`function\\s+${funcName}\\s*\\([^)]*?\\b${paramName}\\s*([=:])`,
|
|
155
|
+
's'
|
|
156
|
+
)
|
|
157
|
+
const match = source.match(funcPattern)
|
|
158
|
+
if (!match) return false
|
|
159
|
+
return match[1] === ':'
|
|
160
|
+
}
|
|
161
|
+
|
|
142
162
|
/**
|
|
143
163
|
* Extract type info for a single function declaration
|
|
144
164
|
*/
|
|
@@ -146,7 +166,8 @@ function extractFunctionTypeInfo(
|
|
|
146
166
|
func: FunctionDeclaration,
|
|
147
167
|
originalSource: string,
|
|
148
168
|
requiredParams: Set<string>,
|
|
149
|
-
returnTypeStr: string | null
|
|
169
|
+
returnTypeStr: string | null,
|
|
170
|
+
inputSource?: string
|
|
150
171
|
): { types: TJSTypeInfo; warnings: string[] } {
|
|
151
172
|
const warnings: string[] = []
|
|
152
173
|
|
|
@@ -205,9 +226,19 @@ function extractFunctionTypeInfo(
|
|
|
205
226
|
param.left.type === 'Identifier'
|
|
206
227
|
) {
|
|
207
228
|
const paramInfo = parseParameter(param, requiredParams)
|
|
229
|
+
// Determine if this param used `:` (required) or `=` (optional).
|
|
230
|
+
// The global requiredParams set is name-based, which fails when
|
|
231
|
+
// two functions share a param name with different syntax.
|
|
232
|
+
// Use the raw input source to check the actual syntax.
|
|
233
|
+
const isRequired = isParamRequiredInSource(
|
|
234
|
+
inputSource || '',
|
|
235
|
+
func.id?.name || '',
|
|
236
|
+
param.left.name
|
|
237
|
+
)
|
|
208
238
|
params[param.left.name] = {
|
|
209
239
|
...paramInfo,
|
|
210
|
-
required:
|
|
240
|
+
required: isRequired,
|
|
241
|
+
default: isRequired ? null : paramInfo.example ?? paramInfo.default,
|
|
211
242
|
description: tdoc.params[param.left.name],
|
|
212
243
|
}
|
|
213
244
|
} else if (param.type === 'ObjectPattern') {
|
|
@@ -346,7 +377,10 @@ function generateInlineValidationCode(
|
|
|
346
377
|
const typeCheck = generateTypeCheckExpr(paramName, param.type)
|
|
347
378
|
|
|
348
379
|
if (typeCheck) {
|
|
349
|
-
const expectedType =
|
|
380
|
+
const expectedType =
|
|
381
|
+
param.type.kind === 'union'
|
|
382
|
+
? (param.type as any).members.map((m: any) => m.kind).join(' | ')
|
|
383
|
+
: param.type.kind
|
|
350
384
|
if (param.required) {
|
|
351
385
|
lines.push(
|
|
352
386
|
`if (${typeCheck}) return __tjs.typeError('${path}', '${expectedType}', ${paramName});`
|
|
@@ -533,6 +567,9 @@ export function transpileToJS(
|
|
|
533
567
|
|
|
534
568
|
// Collect insertions: { position, text } to be applied in reverse order
|
|
535
569
|
const insertions: { position: number; text: string }[] = []
|
|
570
|
+
// Collect deletions for | union suffixes in param defaults
|
|
571
|
+
// e.g. `x = false | undefined` -> `x = false` (the `| undefined` is type-only)
|
|
572
|
+
const deletions: { start: number; end: number }[] = []
|
|
536
573
|
|
|
537
574
|
// Process each function
|
|
538
575
|
for (const func of functions) {
|
|
@@ -564,11 +601,40 @@ export function transpileToJS(
|
|
|
564
601
|
func,
|
|
565
602
|
originalSource,
|
|
566
603
|
requiredParams,
|
|
567
|
-
returnTypeStr
|
|
604
|
+
returnTypeStr,
|
|
605
|
+
cleanSource
|
|
568
606
|
)
|
|
569
607
|
warnings.push(...funcWarnings)
|
|
570
608
|
allTypes[funcName] = types
|
|
571
609
|
|
|
610
|
+
// Clean up param defaults in the emitted JS.
|
|
611
|
+
// After colon→equals transform, `x: false | undefined` becomes
|
|
612
|
+
// `x = false | undefined` in the parsed source.
|
|
613
|
+
// - For required params (`:` syntax), strip the entire `= value` — there's
|
|
614
|
+
// no JS default for required params, the value is a type annotation only.
|
|
615
|
+
// - For union defaults, strip just the `| suffix` to avoid bitwise OR.
|
|
616
|
+
for (const param of func.params) {
|
|
617
|
+
if (param.type === 'AssignmentPattern') {
|
|
618
|
+
const paramName =
|
|
619
|
+
(param as any).left?.name || (param as any).left?.value
|
|
620
|
+
const paramInfo = paramName ? types.params[paramName] : null
|
|
621
|
+
|
|
622
|
+
if (paramInfo?.required && paramInfo.default === null) {
|
|
623
|
+
// Required param — strip entire `= value` from JS output
|
|
624
|
+
deletions.push({
|
|
625
|
+
start: (param as any).left.end,
|
|
626
|
+
end: (param as any).right.end,
|
|
627
|
+
})
|
|
628
|
+
} else {
|
|
629
|
+
// Optional param with union — strip just the `| suffix`
|
|
630
|
+
const right = (param as any).right
|
|
631
|
+
if (right.type === 'BinaryExpression' && right.operator === '|') {
|
|
632
|
+
deletions.push({ start: right.left.end, end: right.end })
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
572
638
|
// Determine safety options
|
|
573
639
|
// Module-level "safety none" makes ALL functions unsafe (no validation)
|
|
574
640
|
const isUnsafe =
|
|
@@ -649,10 +715,27 @@ export function transpileToJS(
|
|
|
649
715
|
}
|
|
650
716
|
}
|
|
651
717
|
|
|
652
|
-
// Apply
|
|
653
|
-
|
|
654
|
-
|
|
718
|
+
// Apply deletions first (reverse order to maintain offsets), then insertions.
|
|
719
|
+
// Deletions strip | union suffixes from param defaults in the output JS.
|
|
720
|
+
deletions.sort((a, b) => b.start - a.start)
|
|
655
721
|
let code = preprocessed.source
|
|
722
|
+
for (const { start, end } of deletions) {
|
|
723
|
+
code = code.slice(0, start) + code.slice(end)
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Adjust insertion positions for any deletions that shifted offsets
|
|
727
|
+
for (const ins of insertions) {
|
|
728
|
+
let shift = 0
|
|
729
|
+
for (const del of deletions) {
|
|
730
|
+
if (del.start < ins.position) {
|
|
731
|
+
shift += del.end - del.start
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
ins.position -= shift
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Apply insertions in reverse position order
|
|
738
|
+
insertions.sort((a, b) => b.position - a.position)
|
|
656
739
|
for (const { position, text } of insertions) {
|
|
657
740
|
code = code.slice(0, position) + text + code.slice(position)
|
|
658
741
|
}
|
|
@@ -1002,6 +1085,13 @@ function generateTypeCheckExpr(
|
|
|
1002
1085
|
case 'object':
|
|
1003
1086
|
// For nested objects, just check it's an object (deep validation is separate)
|
|
1004
1087
|
return `(typeof ${fieldPath} !== 'object' || ${fieldPath} === null || Array.isArray(${fieldPath}))`
|
|
1088
|
+
case 'union': {
|
|
1089
|
+
const checks = (type as any).members
|
|
1090
|
+
.map((m: TypeDescriptor) => generateTypeCheckExpr(fieldPath, m))
|
|
1091
|
+
.filter((c: string | null) => c !== null)
|
|
1092
|
+
if (checks.length === 0) return null
|
|
1093
|
+
return `(${checks.join(' && ')})`
|
|
1094
|
+
}
|
|
1005
1095
|
case 'any':
|
|
1006
1096
|
return null // No check needed
|
|
1007
1097
|
default:
|
package/src/lang/from-ts.test.ts
CHANGED
|
@@ -19,13 +19,13 @@ describe('TypeScript to TJS Transpiler', () => {
|
|
|
19
19
|
expect(result.code).toContain('b: 0')
|
|
20
20
|
})
|
|
21
21
|
|
|
22
|
-
it('should convert optional params to
|
|
22
|
+
it('should convert optional params to union with undefined', () => {
|
|
23
23
|
const result = fromTS(
|
|
24
24
|
`function greet(name: string, title?: string) { return name }`,
|
|
25
25
|
{ emitTJS: true }
|
|
26
26
|
)
|
|
27
27
|
expect(result.code).toContain("name: ''")
|
|
28
|
-
expect(result.code).toContain("title
|
|
28
|
+
expect(result.code).toContain("title: '' | undefined")
|
|
29
29
|
})
|
|
30
30
|
|
|
31
31
|
it('should convert return type to -! annotation (skip signature test)', () => {
|
|
@@ -57,7 +57,7 @@ describe('TypeScript to TJS Transpiler', () => {
|
|
|
57
57
|
`function find(id: string): string | null { return null }`,
|
|
58
58
|
{ emitTJS: true }
|
|
59
59
|
)
|
|
60
|
-
expect(result.code).toContain("-! ''
|
|
60
|
+
expect(result.code).toContain("-! '' | null") // -! for TS-transpiled
|
|
61
61
|
})
|
|
62
62
|
|
|
63
63
|
it('should preserve default values', () => {
|
package/src/lang/inference.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* 10 -> { kind: 'number' }
|
|
7
7
|
* ['string'] -> { kind: 'array', items: { kind: 'string' } }
|
|
8
8
|
* { name: 'string' } -> { kind: 'object', shape: { name: { kind: 'string' } } }
|
|
9
|
-
* 'string'
|
|
10
|
-
* 'string'
|
|
9
|
+
* 'string' | null -> { kind: 'string', nullable: true }
|
|
10
|
+
* 'string' | 0 -> { kind: 'union', members: [{ kind: 'string' }, { kind: 'number' }] }
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { parseExpressionAt } from 'acorn'
|
|
@@ -71,24 +71,8 @@ export function inferTypeFromValue(node: Expression): TypeDescriptor {
|
|
|
71
71
|
const { operator, left, right } = node as any
|
|
72
72
|
|
|
73
73
|
if (operator === '||') {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
// type || null means nullable type
|
|
78
|
-
if (rightType.kind === 'null') {
|
|
79
|
-
return { ...leftType, nullable: true }
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// null || type means nullable type (reverse)
|
|
83
|
-
if (leftType.kind === 'null') {
|
|
84
|
-
return { ...rightType, nullable: true }
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// type1 || type2 means union
|
|
88
|
-
return {
|
|
89
|
-
kind: 'union',
|
|
90
|
-
members: [leftType, rightType],
|
|
91
|
-
}
|
|
74
|
+
// || is JavaScript logical OR — infer type from left operand
|
|
75
|
+
return inferTypeFromValue(left)
|
|
92
76
|
}
|
|
93
77
|
|
|
94
78
|
if (operator === '&&') {
|
|
@@ -106,6 +90,27 @@ export function inferTypeFromValue(node: Expression): TypeDescriptor {
|
|
|
106
90
|
return { kind: 'any' }
|
|
107
91
|
}
|
|
108
92
|
|
|
93
|
+
case 'BinaryExpression': {
|
|
94
|
+
const { operator, left, right } = node as any
|
|
95
|
+
// | means union type (e.g., 0 | null, '' | undefined)
|
|
96
|
+
if (operator === '|') {
|
|
97
|
+
const leftType = inferTypeFromValue(left)
|
|
98
|
+
const rightType = inferTypeFromValue(right)
|
|
99
|
+
|
|
100
|
+
if (rightType.kind === 'null') {
|
|
101
|
+
return { ...leftType, nullable: true }
|
|
102
|
+
}
|
|
103
|
+
if (leftType.kind === 'null') {
|
|
104
|
+
return { ...rightType, nullable: true }
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
kind: 'union',
|
|
108
|
+
members: [leftType, rightType],
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { kind: 'any' }
|
|
112
|
+
}
|
|
113
|
+
|
|
109
114
|
case 'Identifier': {
|
|
110
115
|
// Handle undefined as a type
|
|
111
116
|
if ((node as any).name === 'undefined') {
|
|
@@ -282,6 +287,15 @@ export function extractLiteralValue(node: Expression): any {
|
|
|
282
287
|
}
|
|
283
288
|
return undefined
|
|
284
289
|
|
|
290
|
+
case 'BinaryExpression': {
|
|
291
|
+
const { operator, left } = node as any
|
|
292
|
+
// | is union type — extract the left (primary) example value
|
|
293
|
+
if (operator === '|') {
|
|
294
|
+
return extractLiteralValue(left)
|
|
295
|
+
}
|
|
296
|
+
return undefined
|
|
297
|
+
}
|
|
298
|
+
|
|
285
299
|
case 'LogicalExpression': {
|
|
286
300
|
const { operator, left, right } = node as any
|
|
287
301
|
if (operator === '&&') {
|
package/src/lang/parser.test.ts
CHANGED
|
@@ -294,9 +294,9 @@ test 'always fails' { throw new Error('intentional') }
|
|
|
294
294
|
expect(signature.parameters.limit.default).toBe(10)
|
|
295
295
|
})
|
|
296
296
|
|
|
297
|
-
it('should handle nullable types with
|
|
297
|
+
it('should handle nullable types with | syntax', () => {
|
|
298
298
|
const { signature } = transpile(`
|
|
299
|
-
function test(filter: 'default'
|
|
299
|
+
function test(filter: 'default' | null) {
|
|
300
300
|
return { filter }
|
|
301
301
|
}
|
|
302
302
|
`)
|
|
@@ -304,9 +304,9 @@ test 'always fails' { throw new Error('intentional') }
|
|
|
304
304
|
expect(signature.parameters.filter.type.nullable).toBe(true)
|
|
305
305
|
})
|
|
306
306
|
|
|
307
|
-
it('should handle union types with
|
|
307
|
+
it('should handle union types with | syntax', () => {
|
|
308
308
|
const { signature } = transpile(`
|
|
309
|
-
function test(id: 'abc123'
|
|
309
|
+
function test(id: 'abc123' | 42) {
|
|
310
310
|
return { id }
|
|
311
311
|
}
|
|
312
312
|
`)
|
|
@@ -37,14 +37,16 @@ describe('TJS Emitter', () => {
|
|
|
37
37
|
expect(result.types.test.params.optional.required).toBe(false)
|
|
38
38
|
})
|
|
39
39
|
|
|
40
|
-
it('should
|
|
40
|
+
it('should strip colon type annotations from output (no default for required params)', () => {
|
|
41
41
|
const result = transpileToJS(`
|
|
42
42
|
function greet(name: 'world') {
|
|
43
43
|
return name
|
|
44
44
|
}
|
|
45
45
|
`)
|
|
46
|
-
|
|
46
|
+
// Required `:` params have no default in JS output
|
|
47
|
+
expect(result.code).toContain('function greet(name)')
|
|
47
48
|
expect(result.code).not.toContain("name: 'world'")
|
|
49
|
+
expect(result.code).not.toContain("name = 'world'")
|
|
48
50
|
})
|
|
49
51
|
|
|
50
52
|
it('should handle object type annotations', () => {
|
|
@@ -225,7 +227,7 @@ function greet(name: 'world') {
|
|
|
225
227
|
return x
|
|
226
228
|
}
|
|
227
229
|
`)
|
|
228
|
-
expect(result.code).toContain('function test(x
|
|
230
|
+
expect(result.code).toContain('function test(x)')
|
|
229
231
|
expect(result.code).not.toContain('!')
|
|
230
232
|
})
|
|
231
233
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { describe, test, expect } from 'bun:test'
|
|
16
|
-
import { transpileToJS, fromTS } from './index'
|
|
16
|
+
import { transpileToJS, fromTS, tjs } from './index'
|
|
17
17
|
|
|
18
18
|
// Helper to get the first function's metadata from the Record
|
|
19
19
|
function getFirstFunc(metadata: Record<string, any>) {
|
|
@@ -125,9 +125,9 @@ describe('Basic Types', () => {
|
|
|
125
125
|
// =============================================================================
|
|
126
126
|
|
|
127
127
|
describe('Union Types', () => {
|
|
128
|
-
test('union with
|
|
128
|
+
test('union with | (string or integer)', () => {
|
|
129
129
|
const { metadata } = transpileToJS(`
|
|
130
|
-
function flexible(id: ''
|
|
130
|
+
function flexible(id: '' | 0) {
|
|
131
131
|
return String(id)
|
|
132
132
|
}
|
|
133
133
|
`)
|
|
@@ -135,9 +135,9 @@ describe('Union Types', () => {
|
|
|
135
135
|
expect(getFirstFunc(metadata).params.id.type.members?.length).toBe(2)
|
|
136
136
|
})
|
|
137
137
|
|
|
138
|
-
test('nullable with
|
|
138
|
+
test('nullable with | null', () => {
|
|
139
139
|
const { metadata } = transpileToJS(`
|
|
140
|
-
function maybeString(s: ''
|
|
140
|
+
function maybeString(s: '' | null) {
|
|
141
141
|
return s ?? 'default'
|
|
142
142
|
}
|
|
143
143
|
`)
|
|
@@ -153,18 +153,7 @@ describe('Union Types', () => {
|
|
|
153
153
|
expect(types?.flexible.params.id.type.kind).toBe('union')
|
|
154
154
|
})
|
|
155
155
|
|
|
156
|
-
test('union return type with
|
|
157
|
-
const { metadata } = transpileToJS(`
|
|
158
|
-
function find(id: 0) -! { name: '' } || null {
|
|
159
|
-
return null
|
|
160
|
-
}
|
|
161
|
-
`)
|
|
162
|
-
expect(getFirstFunc(metadata).returns?.kind).toBe('object')
|
|
163
|
-
expect(getFirstFunc(metadata).returns?.nullable).toBe(true)
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
// TODO: Union return types like `{ obj } | null` not yet supported in parser
|
|
167
|
-
test.skip('union return type with | (TS style)', () => {
|
|
156
|
+
test('union return type with | (nullable object)', () => {
|
|
168
157
|
const { metadata } = transpileToJS(`
|
|
169
158
|
function find(id: 0) -! { name: '' } | null {
|
|
170
159
|
return null
|
|
@@ -858,6 +847,20 @@ describe('Real-World Patterns', () => {
|
|
|
858
847
|
// Generic T becomes any, but structure is preserved
|
|
859
848
|
expect(types?.chunk.params.array.type.kind).toBe('array')
|
|
860
849
|
})
|
|
850
|
+
|
|
851
|
+
test('?: boolean transpiles to required union param with no JS default', () => {
|
|
852
|
+
const tjsCode = fromTS('function f(excited?: boolean) { return excited }', {
|
|
853
|
+
emitTJS: true,
|
|
854
|
+
}).code
|
|
855
|
+
// TJS should have union annotation
|
|
856
|
+
expect(tjsCode).toContain('excited: false | undefined')
|
|
857
|
+
const jsResult = tjs(tjsCode)
|
|
858
|
+
// JS should not have default or bitwise OR — `:` means required
|
|
859
|
+
expect(jsResult.code).not.toMatch(/excited = false/)
|
|
860
|
+
expect(jsResult.code).not.toMatch(/excited = false \| undefined/)
|
|
861
|
+
// Should have union type check
|
|
862
|
+
expect(jsResult.code).toContain("typeof excited !== 'boolean'")
|
|
863
|
+
})
|
|
861
864
|
})
|
|
862
865
|
|
|
863
866
|
// =============================================================================
|