tjs-lang 0.5.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tjs-lang",
3
- "version": "0.5.2",
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",
@@ -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 equals syntax', () => {
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
 
@@ -47,7 +47,14 @@ describe('TS → TJS conversion quality', () => {
47
47
  const ts = `function toggle(flag: boolean): boolean { return !flag }`
48
48
  const { code } = fromTS(ts, { emitTJS: true })
49
49
 
50
- expect(code).toContain('flag: true')
50
+ expect(code).toContain('flag: false')
51
+ })
52
+
53
+ it('converts optional boolean param to union with undefined', () => {
54
+ const ts = `function greet(name: string, excited?: boolean): string { return excited ? name + '!' : name }`
55
+ const { code } = fromTS(ts, { emitTJS: true })
56
+
57
+ expect(code).toContain('excited: false | undefined')
51
58
  })
52
59
 
53
60
  it('converts array param correctly', () => {
@@ -78,7 +85,7 @@ describe('TS → TJS conversion quality', () => {
78
85
  const { code } = fromTS(ts, { emitTJS: true })
79
86
 
80
87
  expect(code).toContain("url: ''")
81
- expect(code).toContain('timeout = 0')
88
+ expect(code).toContain('timeout: 0.0 | undefined')
82
89
  })
83
90
  })
84
91
 
@@ -101,7 +108,7 @@ describe('TS → TJS conversion quality', () => {
101
108
  const ts = `function isValid(): boolean { return true }`
102
109
  const { code } = fromTS(ts, { emitTJS: true })
103
110
 
104
- expect(code).toContain('-! true')
111
+ expect(code).toContain('-! false')
105
112
  })
106
113
 
107
114
  it('converts object return type to -! syntax', () => {
@@ -288,18 +295,18 @@ class Api {
288
295
  })
289
296
 
290
297
  describe('nullable types', () => {
291
- it('converts T | null to T || null', () => {
298
+ it('converts T | null to T | null', () => {
292
299
  const ts = `function maybe(x: string | null): string | null { return x }`
293
300
  const { code } = fromTS(ts, { emitTJS: true })
294
301
 
295
- expect(code).toContain("'' || null")
302
+ expect(code).toContain("'' | null")
296
303
  })
297
304
 
298
- it('converts T | undefined to T || undefined', () => {
305
+ it('converts T | undefined to T | undefined', () => {
299
306
  const ts = `function maybe(x: number | undefined): number | undefined { return x }`
300
307
  const { code } = fromTS(ts, { emitTJS: true })
301
308
 
302
- expect(code).toContain('0 || undefined')
309
+ expect(code).toContain('0 | undefined')
303
310
  })
304
311
  })
305
312
 
@@ -371,12 +378,21 @@ class Api {
371
378
 
372
379
  describe('TJS → JS transpilation quality', () => {
373
380
  describe('colon syntax transformation', () => {
374
- it('transforms colon params to defaults in output', () => {
381
+ it('strips colon type annotations from output (required params get no default)', () => {
375
382
  const source = `function greet(name: 'World') { return name }`
376
383
  const { code } = tjs(source)
377
384
 
378
- expect(code).toContain('name = ')
385
+ // Required params (`:` syntax) should have no default in JS
386
+ expect(code).toContain('function greet(name)')
379
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'")
380
396
  })
381
397
  })
382
398
 
@@ -624,12 +640,12 @@ console.log(second())
624
640
  expect(code).not.toContain('y: string')
625
641
  })
626
642
 
627
- it('uses equals syntax for optional params', () => {
643
+ it('uses union with undefined for optional params', () => {
628
644
  const ts = `function test(x?: number, y?: string): void { }`
629
645
  const { code } = fromTS(ts, { emitTJS: true })
630
646
 
631
- expect(code).toContain('x = 0')
632
- expect(code).toContain("y = ''")
647
+ expect(code).toContain('x: 0.0 | undefined')
648
+ expect(code).toContain("y: '' | undefined")
633
649
  })
634
650
 
635
651
  it('uses -! syntax for return types (skip signature test)', () => {
@@ -1247,7 +1263,9 @@ function test(required: string, optional?: number): void { }
1247
1263
  const { types } = tjs(tjsCode)
1248
1264
 
1249
1265
  expect(types?.test?.params?.required?.required).toBe(true)
1250
- expect(types?.test?.params?.optional?.required).toBe(false)
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')
1251
1269
  })
1252
1270
  })
1253
1271
  })
@@ -1780,7 +1798,7 @@ function format(x: '') -! '' { return x + '!' }
1780
1798
  })
1781
1799
 
1782
1800
  it('error short-circuits function body', () => {
1783
- let bodyExecuted = false
1801
+ const bodyExecuted = false
1784
1802
 
1785
1803
  const { code } = tjs(`
1786
1804
  function process(x: '') -! '' {
@@ -1927,4 +1945,46 @@ function divide(a: 10, b: 2) -? { value: 0, error = '' } {
1927
1945
  expect(result.code).toContain('"error"')
1928
1946
  })
1929
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
+ })
1930
1990
  })
@@ -139,7 +139,7 @@ function typeToExample(
139
139
  case ts.SyntaxKind.NumberKeyword:
140
140
  return '0.0'
141
141
  case ts.SyntaxKind.BooleanKeyword:
142
- return 'true'
142
+ return 'false'
143
143
  case ts.SyntaxKind.NullKeyword:
144
144
  return 'null'
145
145
  case ts.SyntaxKind.UndefinedKeyword:
@@ -296,10 +296,10 @@ function typeToExample(
296
296
  const hasUndefined = unionType.types.some(isUndefinedType)
297
297
 
298
298
  if (nonNullTypes.length === 1 && (hasNull || hasUndefined)) {
299
- // Nullable type: T | null -> T || null
299
+ // Nullable type: T | null -> T | null
300
300
  const baseExample = typeToExample(nonNullTypes[0], checker)
301
- if (hasNull) return `${baseExample} || null`
302
- if (hasUndefined) return `${baseExample} || undefined`
301
+ if (hasNull) return `${baseExample} | null`
302
+ if (hasUndefined) return `${baseExample} | undefined`
303
303
  }
304
304
 
305
305
  // General union: use first type as example
@@ -916,7 +916,7 @@ function transformFunctionToTJS(
916
916
  warnings?: string[],
917
917
  includeLineNumber?: boolean
918
918
  ): string {
919
- const params: string[] = []
919
+ const params = transformParams(node.parameters, sourceFile, warnings)
920
920
 
921
921
  // Get line number (1-indexed) for source mapping
922
922
  const { line } = sourceFile.getLineAndCharacterOfPosition(
@@ -924,27 +924,6 @@ function transformFunctionToTJS(
924
924
  )
925
925
  const lineComment = includeLineNumber ? `/* line ${line + 1} */\n` : ''
926
926
 
927
- for (const param of node.parameters) {
928
- const name = param.name.getText(sourceFile)
929
- const isOptional = !!param.questionToken || !!param.initializer
930
- const typeExample = typeToExample(param.type, undefined, warnings)
931
-
932
- if (param.initializer) {
933
- // Has default value - use it directly
934
- const defaultText = param.initializer.getText(sourceFile)
935
- params.push(`${name} = ${defaultText}`)
936
- } else if (typeExample === 'any' || typeExample === 'undefined') {
937
- // any/undefined type - no annotation in TJS (bare name means any)
938
- params.push(name)
939
- } else if (isOptional) {
940
- // Optional without default - use = for optional
941
- params.push(`${name} = ${typeExample}`)
942
- } else {
943
- // Required - use : for required
944
- params.push(`${name}: ${typeExample}`)
945
- }
946
- }
947
-
948
927
  const funcName =
949
928
  explicitName ||
950
929
  (ts.isFunctionDeclaration(node) && node.name
@@ -1203,8 +1182,9 @@ function transformParams(
1203
1182
  // any/undefined type - no annotation in TJS (bare name means any)
1204
1183
  params.push(name)
1205
1184
  } else if (isOptional) {
1206
- // Optional without default - use = for optional
1207
- params.push(`${name} = ${typeExample}`)
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`)
1208
1188
  } else {
1209
1189
  // Required - use : for required
1210
1190
  params.push(`${name}: ${typeExample}`)
@@ -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: requiredParams.has(param.left.name),
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 = param.type.kind
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 insertions in reverse position order (to maintain correct offsets)
653
- insertions.sort((a, b) => b.position - a.position)
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/eval.ts CHANGED
@@ -14,6 +14,27 @@ import { transpile } from './core'
14
14
  let _vm: AgentVM<Record<string, never>> | null = null
15
15
  const getVM = () => (_vm ??= new AgentVM())
16
16
 
17
+ /**
18
+ * Walk an AST and wrap return values in { __result: value } objects.
19
+ * This lets Eval/SafeFunction return arbitrary values through the VM,
20
+ * which enforces strict object returns for agent composability.
21
+ */
22
+ function wrapReturnValues(node: any): void {
23
+ if (!node || typeof node !== 'object') return
24
+ if (Array.isArray(node)) {
25
+ for (const child of node) wrapReturnValues(child)
26
+ return
27
+ }
28
+ if (node.op === 'return' && 'value' in node) {
29
+ node.value = { __result: node.value }
30
+ }
31
+ // Recurse into steps (seq), branches (if/else), etc.
32
+ if (node.steps) wrapReturnValues(node.steps)
33
+ if (node.then) wrapReturnValues(node.then)
34
+ if (node.else) wrapReturnValues(node.else)
35
+ if (node.body) wrapReturnValues(node.body)
36
+ }
37
+
17
38
  /** Capabilities that can be injected into SafeFunction/Eval */
18
39
  export interface SafeCapabilities {
19
40
  /** Fetch function for HTTP requests */
@@ -65,14 +86,24 @@ export async function Eval(options: EvalOptions): Promise<{
65
86
  try {
66
87
  const { ast } = transpile(wrappedCode)
67
88
 
89
+ // Box return values in objects for VM strict-return compliance.
90
+ // Walk AST and wrap each { op: 'return', value } into
91
+ // { op: 'return', value: { __result: originalValue } }
92
+ wrapReturnValues(ast)
93
+
68
94
  const vmResult = await vm.run(ast, context, {
69
95
  fuel,
70
96
  timeoutMs,
71
97
  capabilities,
72
98
  })
73
99
 
100
+ // Unwrap the boxed result
101
+ const raw = vmResult.result
102
+ const result =
103
+ raw && typeof raw === 'object' && '__result' in raw ? raw.__result : raw
104
+
74
105
  return {
75
- result: vmResult.result,
106
+ result,
76
107
  fuelUsed: vmResult.fuelUsed,
77
108
  error: vmResult.error
78
109
  ? { message: vmResult.error.message || String(vmResult.error) }
@@ -128,6 +159,9 @@ export async function SafeFunction(options: SafeFunctionOptions): Promise<
128
159
  // Pre-compile the AST (done once at creation time)
129
160
  const { ast } = transpile(source)
130
161
 
162
+ // Box return values for VM strict-return compliance
163
+ wrapReturnValues(ast)
164
+
131
165
  // Return a function that runs the pre-compiled AST
132
166
  return async (...args: unknown[]) => {
133
167
  const context: Record<string, unknown> = {}
@@ -142,8 +176,13 @@ export async function SafeFunction(options: SafeFunctionOptions): Promise<
142
176
  capabilities,
143
177
  })
144
178
 
179
+ // Unwrap the boxed result
180
+ const raw = vmResult.result
181
+ const result =
182
+ raw && typeof raw === 'object' && '__result' in raw ? raw.__result : raw
183
+
145
184
  return {
146
- result: vmResult.result,
185
+ result,
147
186
  fuelUsed: vmResult.fuelUsed,
148
187
  error: vmResult.error
149
188
  ? { message: vmResult.error.message || String(vmResult.error) }
@@ -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 = syntax', () => {
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("-! '' || null") // -! for TS-transpiled
60
+ expect(result.code).toContain("-! '' | null") // -! for TS-transpiled
61
61
  })
62
62
 
63
63
  it('should preserve default values', () => {
@@ -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' || null -> { kind: 'string', nullable: true }
10
- * 'string' || 0 -> { kind: 'union', members: [{ kind: 'string' }, { kind: 'number' }] }
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
- const leftType = inferTypeFromValue(left)
75
- const rightType = inferTypeFromValue(right)
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 === '&&') {
@@ -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 colon syntax', () => {
297
+ it('should handle nullable types with | syntax', () => {
298
298
  const { signature } = transpile(`
299
- function test(filter: 'default' || null) {
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 colon syntax', () => {
307
+ it('should handle union types with | syntax', () => {
308
308
  const { signature } = transpile(`
309
- function test(id: 'abc123' || 42) {
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 convert colon syntax to default values in output', () => {
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
- expect(result.code).toContain("name = 'world'")
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 = 0)')
230
+ expect(result.code).toContain('function test(x)')
229
231
  expect(result.code).not.toContain('!')
230
232
  })
231
233