Skip to content

Expo Modules v2 design docs#8

Draft
tsapeta wants to merge 15 commits into
mainfrom
@tsapeta/expo-modules-v2-rfc
Draft

Expo Modules v2 design docs#8
tsapeta wants to merge 15 commits into
mainfrom
@tsapeta/expo-modules-v2-rfc

Conversation

@tsapeta

@tsapeta tsapeta commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Design docs for extending the Swift macros to cover the full Expo Modules surface, staged in three phases (DSL coverage / direct JSI binding, performance, TypeScript type generation). Draft for review.

  • docs/expo-modules-v2.md — the Apple/Swift design.
  • docs/expo-modules-v2-android.md — the Android/Kotlin companion.

Some of it has already shipped to main (see Status); the doc tracks what's landed vs. designed and records the cross-repo core dependencies. It also notes prior art (JavaScriptKit / BridgeJS) and the AI-agent ergonomics rationale for the macro surface over the DSL.

Status

Doc is draft, for design review — not intended to merge as-is. The doc itself describes work that has already landed on main via separate PRs (reconciled through #31):

Still designed-only, pure-macro (unblocked, not built): overloaded-@JS func grouping/dispatch (duplicates collide silently today), @Union, and the decode index-bearing error wrap.

Still designed-only, blocked on core: Views (@ViewProps/@ExpoView, the view contract), @JS static var (StaticProperty), and @Event(sync:)'s emitSync runtime.

Android / Kotlin (KSP): nothing built yet — no processor, annotations, or scanner. Tracked separately in docs/expo-modules-v2-android.md; it targets the same platform-neutral JS/TypeScript model.

Open questions and core dependencies are listed at the end of each doc.

@tsapeta tsapeta changed the title Expo Modules v2 design doc Expo Modules v2 design docs Jun 13, 2026
@tsapeta
tsapeta force-pushed the @tsapeta/expo-modules-v2-rfc branch from 50b159f to 9d0af96 Compare June 13, 2026 13:59
tsapeta added 14 commits July 8, 2026 10:12
A staged design for extending the Swift macros to cover the full Expo
Modules surface, in three phases:

1. DSL coverage — @ExpoModule / @js / @sharedobject / @record / @union /
   @ViewProps / @ExpoView / @event synthesizing today's definition DSL;
   typed events (no EventDispatcher), typed unions, view props as a struct
   with function-typed fields as events, fields-by-default with inferred
   requiredness.
2. Performance — direct JSI binding: the macro builds the JS object via
   expo-modules-jsi (createFunction + per-type casts) instead of the
   dynamic [Any]/toTuple path; generalizes the @OptimizedFunction PoC.
3. TypeScript type generation — reuse expo-type-information to emit .d.ts
   from the annotated source.

Captures the resolved decisions, the verified core signatures, and the
cross-repo core dependencies each phase needs.
Syncs the design doc with the current state:
- @js var binds directly via defineProperty inside _decorateModule (the
  phase-2 direct-JSI form), not the DSL Property(...) entry; notes the
  settability and access-modifier semantics (reflects PR #14).
- Adds a "Why macros over the DSL (incl. AI-agent ergonomics)" section:
  macros are easier for AI agents to author (ordinary Swift,
  signature-as-contract, fewer Expo-specific concepts) and cheaper over a
  session (fewer error/retry loops; Phase 3 gives typed JS for free).
SwiftWasm's JavaScriptKit (BridgeJS) ships macro-based Swift↔JS interop and
independently lands on the same core decisions (value vs reference semantics,
fields-by-default, static markers, generated .d.ts) — validation of this
direction. Notes the deliberate divergence: it uses one @js for all kinds,
while we split @js (members) from @Record/@Union/@ViewProps (types) to track
existing expo-modules-core concepts. Adds the related open question on whether
@js struct/@js enum should error-and-redirect or alias to @Record/@union.
- Add `docs/expo-modules-v2-android.md` — the Android/Kotlin companion design.
- Update `docs/expo-modules-v2.md` to the latest from the working draft.
Name is the last DSL element the macro emits (alone in _synthesizedDefinition
now that members moved to _decorateModule). Document retiring it:
- default case emits nothing — core's existing type-name fallback covers it
  (ModuleDefinition / ModuleHolder), no core change.
- custom name (@ExpoModule("Bar")) → a synthesized _synthesizedModuleName
  static that core reads, feeding both native registration and JS
  __expo_module_name__ so they can't diverge (vs a JS-only write).
- same for @sharedobject class names.
Adds core dependency #7 and records the decision.
Clarify that the custom-name carrier is a plain static stored property —
`static let _synthesizedModuleName = "Bar"` (compile-time literal) —
satisfying a `static var … : String? { get }` AnyModule requirement that
defaults to nil. No computed property or method needed.
The macro knows the final name in every case (class name, or the
@ExpoModule("Bar") arg), so it synthesizes a non-optional, fully-resolved
`static let _synthesizedModuleName` for every module — core never runs
String(describing:) for a macro module. Precedence: registration _name
override → _synthesizedModuleName → type-name fallback (non-macro only).
Update the module-name section to match what shipped: a non-optional
`public static let _jsName` rather than the planned optional
`_synthesizedModuleName` protocol requirement. Core distinguishes a macro
module from a DSL one by presence of `_jsName`, not a `nil` sentinel, and
the property is kind-neutral so `@SharedObject` class names can reuse it.
Add an "Argument validation" subsection to Phase 2 spelling out the
semantics the direct-JSI binding must reproduce from the DSL path:

- Arity is a required..max range, not an exact count: a trailing run of
  `Optional<T>` params may be omitted (`requiredArgumentsCount = total -
  trailingOptionalCount`), thrown as core's `InvalidArgsNumberException`.
- `null`/`undefined`/omitted map by the param's static optionality (`nil`
  for `Optional<T>`, `NullCastException` for a required non-optional).
- Type mismatches throw a typed, index-bearing error wrapped like core's
  `ArgumentCastException`, surfacing as a sync throw or promise rejection.
- Converters stay in core (single source of truth); the macro only selects
  which one to call, statically.

Also flags the shipped/target gap: current codegen emits an exact
`arguments.count ==` check and an ad-hoc `Exception`, which over-rejects an
omitted trailing optional and diverges from the DSL error type.

This sync also brings the repo copy up to date with the plans copy's
Phase 3 (TypeScript generation) section, which had not been committed yet.
The DSL binds a closure, which can't carry Swift default values, so core's
only notion of an omittable arg is `Optional<T>` (omitted ⇒ `nil`). The
macro binds the real function, so a trailing param is omittable if it's
defaulted OR optional: `required = total − trailing-run-of-(defaulted-or-
optional)`. An omitted defaulted slot is left out of the `self.f(...)` call
so Swift applies the default; an omitted optional slot passes `nil`.

Defaults force per-arity call shapes (branch on `arguments.count`), since a
maybe-present arg can't thread through one static call expression; pure
`Optional` ranges don't. An explicit in-range `null`/`undefined` still
decodes through the converter — only true omission triggers a default.

Update the shipped-vs-target gap note: current codegen does exact-count and
ignores defaults entirely.
Add a worked expansion for `resize(width:height:mode:)` mixing a required,
a defaulted, and an optional-no-default param. The binding decodes the
required prefix once, then `switch`es on `arguments.count`, each case
decoding only the present slots: an omitted defaulted param drops its label
(Swift applies the default), an omitted optional passes `nil`.

Correct an earlier claim: the per-arity branch is needed for the whole
omittable trailing run, not just defaults. The arguments buffer is exactly
JS-arity-sized and `arguments[i]` traps out of bounds
(`JavaScriptValuesBuffer.swift:67`) — there's no missing-slot padding like
core's DSL path (`SyncFunctionDefinition.swift:170`), so the binding can't
index a slot the caller didn't pass. The switch collapses to a single flat
call only when every param is required.
JS holds one function per name, so N same-named `@JS func`s collapse into
one host function that dispatches on the only things JS exposes at runtime:
`arguments.count` and each argument's `JavaScriptValue.Kind` (plus the
`isArray()`/`isFunction()`/`isTypedArray()` object refinements). Three tiers:

- Arity-disjoint: dispatch on count; each branch calls a different function
  (the per-arity switch already in hand). Always supported.
- Same-arity, kind-distinguishable: dispatch on the leftmost differing
  argument's JS kind (`String`↔`number`, `object`↔`array`, etc.). Supported
  when those kinds are disjoint.
- Runtime-indistinguishable (`Int` vs `Double`, two records, two arrays,
  return-type-only): no dispatcher possible, so a compile-time diagnostic.

The kind check is syntactic on declared types, so it resolves at expansion
time with a clear error rather than silent last-writer-wins. First cut is
single-position dispatch, not full Swift overload resolution. Flag the
interim: reject duplicate JS names until the dispatcher lands.
…d scoping

Add a "Function signature surface" section closing gaps the validation and
overload work implied but didn't state:

- Accepted vs. rejected `@JS func` parameter shapes (variadic, `inout`,
  generic/opaque rejected with a diagnostic; `T`/`T?`/defaults/closures
  accepted; extra labels and `rethrows` fine).
- Closure/callback arguments as a new capability the DSL lacks (closures
  aren't `AnyArgument`): the macro wraps an incoming `JavaScriptFunction`
  in a Swift closure, with the `@JavaScriptActor` thread and strong-retain
  lifetime rules an `@escaping` callback needs.
- Error-to-JS mapping: a thrown `Exception` crosses via
  `forwardingSwiftErrorsToJS` into a JS `Error` carrying `message` + `code`;
  sync throws, async rejects; arg index/type ride in the message string,
  same as the DSL path.

Also note overload grouping is per JS object: a static and an instance func
of the same name aren't overloads (different objects), but a `@JS var` and
`@JS func` resolving to one key on one object collide.
@tsapeta
tsapeta force-pushed the @tsapeta/expo-modules-v2-rfc branch from 79357a9 to 5726268 Compare July 8, 2026 08:14
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