Skip to content

feat: add value-expression AST nodes for composing call chains#84

Merged
Emyrk merged 3 commits into
mainfrom
feat/expression-ast-nodes
May 29, 2026
Merged

feat: add value-expression AST nodes for composing call chains#84
Emyrk merged 3 commits into
mainfrom
feat/expression-ast-nodes

Conversation

@Emyrk
Copy link
Copy Markdown
Member

@Emyrk Emyrk commented May 29, 2026

Adds seven bindings nodes that let mutations build TypeScript value expressions structurally instead of via string concatenation:

Node Emits
IdentifierExpression{Name} z, BaseSchema (bare value reference)
PropertyAccessExpression{Expr, Name} expr.name
CallExpression{Expr, Args} expr(args...), composes for chains
ObjectLiteralExpression{Props} + PropertyAssignment{Name, Init} { k: v, ... } in value position
ArrowFunction{Params, RetType, Body} + Parameter{Name, Type} (params): retType => body
TypeQuery{Name} typeof Name (type position)

Foundation for a stacked PR that adds a zod mutation; useful on its own to any caller that wants to inject computed value expressions into the AST.

Implementation notes and test coverage

Why these aren't covered by existing nodes

  • ReferenceType is a TypeScript type-reference node. IdentifierExpression is needed for the value-position case (the z in z.string()); reusing ReferenceType there would emit a type reference where TypeScript expects an expression.
  • TypeLiteralNode is a TypeScript object-type literal ({ k: string }). ObjectLiteralExpression is its value-position sibling ({ k: z.string() }). They share no semantics so they stay distinct.
  • ArrayLiteralType and LiteralType already cover [...] and primitive literals, so this PR doesn't redefine them.

Wiring

  • ts.factory.create* wrappers added to typescript-engine/src/index.ts. dist/main.js rebuilt with pnpm run build.
  • Goja-backed builders added to bindings/bindings.go.
  • ToTypescriptNode dispatches *PropertyAssignment and *Parameter directly (neither is an ExpressionType or DeclarationType because they only appear as children of object literals / arrow functions).
  • ToTypescriptExpressionNode dispatches the six new ExpressionType cases.

Test coverage

bindings/expressions_test.go round-trips every node through goja and verifies the emitted text:

  • TestIdentifierExpression — bare identifier.
  • TestPropertyAccessExpressionz.string.
  • TestCallExpression — no-args, one-arg, chained (z.string().optional()), and nested (z.array(z.string())).
  • TestObjectLiteralExpression — empty, single property, multiple with declaration-order preservation.
  • TestPropertyAssignment — standalone, easier failure bisect.
  • TestArrowFunction — no params, with return type, with typed parameter.
  • TestTypeQuery — standalone and as a generic argument (Foo<typeof FooSchema>).
  • TestComposeZodObject and TestComposeZodLazyRef — integration cases that build the z.object({...}) and z.lazy((): z.ZodType => Schema) shapes the stacked zod mutation will need. If these fail, the follow-up cannot succeed.

go test ./... and go vet ./... clean (one pre-existing unreachable-code warning in convert.go predates this branch).

Stacked follow-up

A feat/zod-mutation PR will add a zod package with an AsSchemas mutation that walks every Interface / Alias in ts.typescriptNodes and replaces it with a VariableStatement (export const FooSchema = z.object({...})) plus an Alias (export type Foo = z.infer<typeof FooSchema>). The schema expression tree is built from the nodes in this PR. Replaces #82's string-builder serializer with an AST mutation composable with ExportTypes, SimplifyOptional, and the other config mutations.


Generated by Coder Agents on behalf of @Emyrk.

Emyrk added 3 commits May 29, 2026 20:01
Adds seven bindings nodes that let mutations build TypeScript value
expressions structurally instead of via string concatenation:

- IdentifierExpression: bare value-position identifier (`z`, `BaseSchema`).
- PropertyAccessExpression: `expr.name`, the building block for method
  chains like `z.string`.
- CallExpression: `expr(args...)`, composes with PropertyAccessExpression
  to build `z.string()` and chained `z.string().optional()`.
- ObjectLiteralExpression + PropertyAssignment: `{ k: v, ... }` in value
  position, distinct from TypeLiteralNode which emits a type literal.
- ArrowFunction + Parameter: `(params): retType => body` so callers can
  produce things like `(): z.ZodType => FooSchema` for cyclic references.
- TypeQuery: `typeof name`, used as a generic argument such as the
  `typeof FooSchema` inside `z.infer<typeof FooSchema>`.

Each node has a corresponding `ts.factory.create*` wrapper in
typescript-engine/src/index.ts, a goja-backed builder in bindings.go, and
dispatch through ToTypescriptNode / ToTypescriptExpressionNode.
typescript-engine/dist/main.js is rebuilt to ship the new factories.

bindings/expressions_test.go round-trips every node and several
compositions through goja, including the chained-call shape
`z.string().optional()`, an inline object literal with property-order
preservation, and the `z.lazy((): z.ZodType => Schema)` self-reference
form. These cover the surface a follow-up zod mutation needs without
binding the bindings to any zod-specific opinions.

Generated by Coder Agents on behalf of @Emyrk.
Align with ReferenceType.Name, VariableDeclaration.Name, and Alias.Name so
the same qualified-name handle flows through value-position references.
Cross-package Prefix set during Go parsing now reaches the emitted name
via .Ref() instead of being silently dropped.

Doc comment on IdentifierExpression now spells out the distinction from
bindings.Identifier: Identifier is a parser-layer qualified-name handle
and is not itself a Node; IdentifierExpression is a tree-layer Node that
embeds an Identifier in expression position.

Test split into bare-name and prefix-is-applied subcases. The prefix case
pins the new behavior; without it, an IdentifierExpression carrying a
prefixed Identifier would emit "Schema" instead of "ExternalSchema" and
would not match the prefixed declaration the rest of guts emits for the
same Identifier.

Generated by Coder Agents on behalf of @Emyrk.
Three review findings, applied together:

1. TypeQuery.Name is now Identifier instead of string. Same reason as the
   IdentifierExpression refactor: `typeof FooSchema` references a declared
   TS name, and the prefix set during Go parsing must flow through .Ref()
   so the TypeQuery and a matching IdentifierExpression for the same
   prefixed Identifier emit aligned names. Without this, a prefixed
   declaration would emit `typeof Foo` while the value-position reference
   emits `ExternalFoo`. Doc updated to spell out the invariant and a
   prefix_is_applied subtest pins the new behavior.

2. Test helper zMethodCall renamed to methodCall(receiver, method, args).
   The bindings know nothing about zod; the helper shouldn't either. The
   helper had "z" hardcoded as the receiver, which leaked the zod
   motivation into general-purpose tests. The new signature reads
   methodCall("z", "string"), exactly as descriptive at the call site,
   without baking domain assumptions into the helper name.

3. ObjectLiteralExpression doc now calls out the modeled subset. TS's
   ObjectLiteralElementLike covers PropertyAssignment,
   ShorthandPropertyAssignment, SpreadAssignment, MethodDeclaration, and
   accessors. We model only PropertyAssignment; the doc says so, matching
   the existing pattern on ImportDeclaration ("Only the named-imports
   form is modeled").

Generated by Coder Agents on behalf of @Emyrk.
@Emyrk Emyrk marked this pull request as ready for review May 29, 2026 22:56
@Emyrk Emyrk merged commit 7b8e1a8 into main May 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant