Skip to content

Auto-conform @ExpoModule classes to AnyModule#5

Draft
tsapeta wants to merge 2 commits into@tsapeta/expo-module-inheritancefrom
@tsapeta/auto-anymodule-conformance
Draft

Auto-conform @ExpoModule classes to AnyModule#5
tsapeta wants to merge 2 commits into@tsapeta/expo-module-inheritancefrom
@tsapeta/auto-anymodule-conformance

Conversation

@tsapeta
Copy link
Copy Markdown
Contributor

@tsapeta tsapeta commented May 4, 2026

Summary

Stacked on top of #4. Two commits:

  1. Auto-conform @ExpoModule classes to AnyModule — emits extension MyModule: AnyModule {} when the class doesn't already inherit from Module, BaseModule, or AnyModule. Replaces the inheritance-required diagnostic from Diagnose missing Module inheritance on @ExpoModule classes #4 with permissive expansion.

  2. Synthesize appContext, init(appContext:), and @ModuleDefinitionBuilder — the macro now fills in everything BaseModule used to provide, so a @ExpoModule class can stand on its own without inheriting from BaseModule. Skips emission when the user provided any of these themselves.

Together these two steps unblock @ExpoModule for classes that need to author the module surface without committing to a specific superclass.

// Minimum: works without any inheritance line
@ExpoModule
final class MyModule {
  func definition() -> ModuleDefinition {
    Name("MyModule")
  }
}

// Expands to:
final class MyModule {
  public weak var appContext: AppContext?

  public required init(appContext: AppContext) {
    self.appContext = appContext
  }

  @ModuleDefinitionBuilder
  func definition() -> ModuleDefinition {
    Name("MyModule")
  }

  public func _exposedDefinition() -> [AnyDefinition] {
    return [
      Name("MyModule")
    ]
  }
}

extension MyModule: AnyModule {}

What gets skipped, and when

User wrote extension: AnyModule appContext init(appContext:) @ModuleDefinitionBuilder
: Module skip (already conforms) skip (BaseModule provides) skip (BaseModule provides) stamp
: BaseModule skip (provides via BaseModule) skip skip stamp
: AnyModule skip (already conforms) emit emit stamp
: SomeOtherBase emit emit emit stamp
no inheritance emit emit emit stamp
user wrote own appContext (orthogonal) skip (orthogonal) (orthogonal)
user wrote own init(appContext:) (orthogonal) (orthogonal) skip (orthogonal)
user already stamped @ModuleDefinitionBuilder (orthogonal) (orthogonal) (orthogonal) skip

Test plan

  • swift test passes — 27 cases total (8 new across the two commits).
  • In a consumer module that uses @ExpoModule without : Module, the iOS build succeeds and JS calls round-trip.
  • In a consumer that writes : Module explicitly, no "redundant conformance" or duplicate-member errors appear.
  • A consumer that hand-rolls one of appContext / init(appContext:) / @ModuleDefinitionBuilder doesn't get a duplicate from the macro.

tsapeta added 2 commits May 4, 2026 23:51
`@ExpoModule` now also conforms to `ExtensionMacro` and emits an
`extension MyModule: AnyModule {}` when the class doesn't already
inherit from `Module`, `BaseModule`, or `AnyModule`. This unblocks
`@ExpoModule` for classes that need a different concrete superclass
(e.g. an existing app-specific base class) — the conformance comes
from the macro instead of being baked into the declaration.

Replaces the inheritance-required diagnostic introduced earlier:
the macro is now permissive about the inheritance line. If the
class doesn't satisfy `AnyModule`'s remaining requirements through
some other mechanism (its own storage and `init(appContext:)`), the
type checker still surfaces a clear error pointing at the generated
extension.

The macro skips emission when the class literally inherits from
`Module`, `BaseModule`, or `AnyModule` — those names already supply
the conformance, and a redundant extension would error.
`@ExpoModule` now fills in everything `BaseModule` used to provide
when the class doesn't inherit from `Module` or `BaseModule`:

- `public weak var appContext: AppContext?` — the module's app-context
  storage, declared `weak` to mirror `BaseModule`.
- `public required init(appContext: AppContext)` — the protocol-required
  initializer, storing the context into the synthesized property.
- `@ModuleDefinitionBuilder` stamped on `func definition() -> ModuleDefinition`
  so the result-builder application is explicit on the user's
  declaration rather than relying on Swift's protocol-requirement
  inference.

The macro skips emission when the user provides their own `appContext`
property, their own `init(appContext:)`, or already stamped
`@ModuleDefinitionBuilder` on `definition()` themselves.

The emission order in the expansion is `appContext` -> `init(appContext:)`
-> `_exposedDefinition()` so storage/init read first when scanning the
generated members.
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