Skip to content

Add Android KSP @ExpoModule/@JS processor#24

Draft
tsapeta wants to merge 1 commit into
mainfrom
tsapeta/android-expo-module-macro
Draft

Add Android KSP @ExpoModule/@JS processor#24
tsapeta wants to merge 1 commit into
mainfrom
tsapeta/android-expo-module-macro

Conversation

@tsapeta

@tsapeta tsapeta commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Introduces android/, the Android counterpart of the Swift macros in apple/. Where Apple uses Swift compiler macros, Android uses a KSP (Kotlin Symbol Processing) processor: the author annotates ordinary Kotlin and the processor generates the Expo module's JS surface at build time.

This is the first in a series of PRs bringing the Expo Modules v2 author-facing model to Android (see ~/Work/plans/expo-modules-v2-android.md). It covers @ExpoModule and @JS only.

What's here

  • annotations/ — the markers an author applies: @ExpoModule on a Module class, @JS on its functions and properties.
  • processor/ — the KSP SymbolProcessor. It reads @ExpoModule/@JS and generates a <Module>.expoModuleDefinition() extension that builds the module's ModuleDefinitionData through core's existing ModuleDefinition { … } DSL.

Author API

@ExpoModule("MyModule")
class MyModule : Module() {
  @JS fun greet(name: String): String = "hi $name"
  @JS suspend fun work(id: String): Int = id.length
  @JS val version: String get() = "1.0"
  @JS var ready: Boolean = false

  // KSP generates files rather than rewriting the class the way a Swift macro
  // adds members to its type, so the author wires the generated definition in
  // with one line.
  override fun definition() = expoModuleDefinition()
}

Covered: sync functions (0..N args), suspend fun (to a JS Promise via AsyncFunction … Coroutine), getter-only and settable properties, @JS("name") overrides, nullable types, and diagnostics for a non-Module class or a private @JS member.

Design notes

  • First-party and Pika-independent. Pika (the IR plugin core uses for @OptimizedRecord) is sealed and can't be extended, so the processor is written from scratch on the KSP API.
  • No core changes required. The generated extension compiles against today's expo-modules-core; the author opts in by returning it from definition(). A later step can add a core convention so definition() doesn't need to be hand-written.
  • @Record is intentionally not here. Core's RecordTypeConverter only branches Pika-vs-reflection with no hook for a generated strategy, so a useful @Record needs a paired core change. It is sequenced for a later PR.

Testing

Tests use kotlin-compile-testing (the KSP analog of the Swift suite's assertExpansion): they run the processor over fixture modules and assert on the generated source, compiling it against minimal stubs of the core DSL. A green run proves the generated code is valid Kotlin that type-checks. Like the apple/ suite, this verifies shape, not that it links against real core, and integration is only proven by building a real module against core.

cd android && ./gradlew :processor:test

15 tests (9 generator unit tests + 6 end-to-end processor tests), all green.

Status

Draft. Author API and generated shape are settled; the core-side discovery convention (so definition() is automatic) and a real-module integration check are follow-ups.

Introduce `android/`, the Android counterpart of the Swift macros in `apple/`.
Where Apple uses Swift compiler macros, Android uses a KSP `SymbolProcessor`:
the author annotates ordinary Kotlin and the processor generates the module's
JS surface at build time.

- `annotations/`: `@ExpoModule` on a `Module` class, `@JS` on functions and
  properties.
- `processor/`: reads those annotations and generates a
  `<Module>.expoModuleDefinition()` extension that builds `ModuleDefinitionData`
  via core's existing `ModuleDefinition { … }` DSL. The author wires it in with
  `override fun definition() = expoModuleDefinition()`, since KSP generates files
  rather than rewriting the class the way a Swift macro does.

Covers sync functions, `suspend fun` (to a JS `Promise` via `AsyncFunction …
Coroutine`), getter-only and settable properties, `@JS("name")` overrides, and
diagnostics for a non-`Module` class or a private `@JS` member.

The generated code compiles against today's `expo-modules-core` with no core
changes. Tests use `kotlin-compile-testing` (the KSP analog of the Swift suite's
`assertExpansion`): they run the processor over fixtures and assert on the
generated source, compiling it against minimal core-DSL stubs. Like `apple/`,
this verifies shape, not that it links against real core.

The processor is first-party and Pika-independent: Pika (core's IR plugin for
`@OptimizedRecord`) is sealed and can't be extended.
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