Expo Modules v2 design docs#8
Draft
tsapeta wants to merge 15 commits into
Draft
Conversation
tsapeta
force-pushed
the
@tsapeta/expo-modules-v2-rfc
branch
from
June 13, 2026 13:59
50b159f to
9d0af96
Compare
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
force-pushed
the
@tsapeta/expo-modules-v2-rfc
branch
from
July 8, 2026 08:14
79357a9 to
5726268
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
mainvia separate PRs (reconciled through #31):@JSfunctions bound directly into the JS object (_decorateModule) — Bind@JSfunctions directly into the JS object via_decorateModule#13@JSproperties bound directly viadefineProperty— Bind@JSproperties directly into the JS object via_decorateModule#14@JS/@Recordboundary-type conformance assertions — Assert@JS/@Recordboundary types conform at compile time #15appContextcapture from primitive-only@JSbindings #16@Eventaccessor macro (typed module + shared-object events) — Add the@Eventaccessor macro for typed module and shared-object events #17_jsName,Name(...)retired — Synthesize the module name as_jsName, drop theName(...)DSL entry #18Exceptions.ArgumentsRangeMismatch— Emit range-based arity check throwing ArgumentsRangeMismatch #19thissync closures — Emit unowned-thisclosures for synchronous@JSbindings #20ExpoModulesScannerCLI withscan-modules— Add a Swift source scanner CLI for the Expo module macros #21@SharedObjectdirect JSI via_decorateSharedObject— Bind@SharedObjectmembers directly into the JS object via_decorateSharedObject#22scan-exportsdeep type-export walk emittingFileTypeInformationSerializedJSON — Implement thescan-exportsCLI subcommand #25@JSbindings and@Recordfield conversions throughJavaScriptCodable— Convert@JSbindings through JavaScriptCodable instead of dynamic types #26, Convert@RecordJS-object field conversions through JavaScriptCodable #27appContextfrom the synthesized JSI hooks — Drop the unusedappContextfrom the synthesized JSI hooks #28switchcodegen fix — Fix invalidswitch-expression codegen for omittable trailing args #29@JSmembers stamped@JavaScriptActor, async@SharedObjectreceiver unwrapped — Stamp async@JSmembers with@JavaScriptActor#30, Unwrap the async@SharedObjectreceiver from an owningthis#31Still designed-only, pure-macro (unblocked, not built): overloaded-
@JS funcgrouping/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:)'semitSyncruntime.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.