/expo-migrate-module
Migrate an existing Apple/Swift Expo native module from the Expo Modules API 1.0 definition DSL to the 2.0 macro API (sometimes called v2) while preserving its JavaScript and TypeScript contract. Use when converting or incrementally adopting @ExpoModule, @JS, @Event,
Claude Code
$ npx -y skills add expo/skills --skill expo-migrate-module --agent claude-code
Codex
$ npx skills add expo/skills --skill expo-migrate-module -a codex -y
Cursor
$ npx skills add expo/skills --skill expo-migrate-module -a cursor -y
Gemini CLI
$ npx skills add expo/skills --skill expo-migrate-module -a gemini-cli -y
GitHub Copilot
$ npx skills add expo/skills --skill expo-migrate-module -a github-copilot -y
OpenCode
$ npx skills add expo/skills --skill expo-migrate-module -a opencode -y
Windsurf
$ npx skills add expo/skills --skill expo-migrate-module -a windsurf -y
SKILL.md
expo-migrate-module.SKILL.mdname: expo-migrate-module
description: Migrate an existing Apple/Swift Expo native module from the Expo Modules API 1.0 definition DSL to the 2.0 macro API (sometimes called v2) while preserving its JavaScript and TypeScript contract. Use when converting or incrementally adopting @ExpoModule, @JS, @Event, @SharedObject, or @Record in an existing module. Do not use for creating a new module, general Expo SDK upgrades, or Android/Kotlin migrations.
license: MIT
Migrate an Expo Module
Migrate the Swift side of an existing Expo module without changing its observable JS API. Treat the current JS/TypeScript surface and tests as the compatibility contract. Leave Kotlin on the 1.0 DSL unless the user explicitly expands the task.
Prerequisite
Use `expo` `58.0.21` or newer. Earlier `58.0.x` releases lack tuple conversion and the `swift:check` and `swift:expand` commands of `expo-modules-cli`. Older ones also lack `lazy` `@JS` properties, `SharedRef<T>` subclasses in `@SharedObject`, static shared-object members, and `.withAsync`, and the oldest lack closure arguments and free-form `Any` return values. SDK 57 also lacks `.concurrent`, `@Union`, and module auto-discovery. Do not target either. Before editing, check the installed version (`npm ls expo`, or the lockfile). If it is older, stop and tell the user to upgrade first.
Check the example app's own dependencies too, not only the module root. The example app is the integration surface you build and launch in step 4, so it needs the same floor.
This is a floor, not a guarantee: the macro and core surface changed in almost every `58.0.x` patch release, so step 2 must still verify the installed source.
The macros are still **experimental and undocumented**: SDK 58 has no page about them in the Expo docs, and the API can still change under you. Say this to the user before a large migration, and prefer incremental mixed mode over converting a module wholesale.
References
- Read `references/migration-map.md` before changing source. It contains the 1.0-to-2.0 mappings, semantic traps, and mixed-mode rules.
- Read `references/example.md` for a full before/after walkthrough of one module through mixed mode to a complete migration. Consult it when you need to see how the per-member rules compose.
- Read `references/compatibility.md` when the checked-out `expo-modules-core` version or branch is not known to support every requested macro. It explains how to verify the actual compile-time and runtime surface instead of guessing from a version number, gives the SDK 58 status of each capability, and shows how to run the module scanners and the `expo-modules-cli` checks.
Workflow
1. Establish the contract
Inspect repository instructions and the worktree before editing. Locate the Swift module classes, records, shared objects, native views, JS/TS bindings, tests, example app, podspec, `expo-module.config.json`, and installed or checked-out `expo-modules-core`.
Inventory every exported item before rewriting it:
- module and shared-object JS names
- function names, arity, labels, defaults, nullability, sync/async behavior, errors, and queue/thread semantics (note which `AsyncFunction` bodies do blocking or long-running work, any `.runOnQueue(...)`, and sync/async pairs that share a body, such as `readSync` and `read`)
- property names, mutability, and constant caching behavior
- event wire names and payload shapes, including events sent with no body (1.0 `sendEvent("name")` sends `{}`)
- record field names, defaults, requiredness, and nullability
- shared-object constructors and instance/static placement (`Function` vs `StaticFunction`/`StaticAsyncFunction`)
- lifecycle hooks and views
Use the TypeScript declarations and JS call sites to resolve ambiguity. Do not silently "improve" requiredness, rename an event, or change sync behavior during a syntax migration.
2. Verify the available 2.0 surface
Inspect the macro declarations and matching core hooks in the dependency actually used by the target. Do not assume that all items in the 2.0 design are present because one macro compiles.
Classify each 1.0 item as:
- **Migrate:** both its macro and required core runtime support exist.
- **Keep in DSL:** mixed mode preserves it safely, or 2.0 lacks an equivalent.
- **Blocked:** migration would alter the JS contract or requires unavailable runtime support.
A DSL member that takes or returns `JavaScriptValue` does not compile on SDK 58, so it cannot stay in the DSL. Migrate it to `@JS` with a `borrowing JavaScriptValue` parameter, as described in `references/migration-map.md`.
Prefer an incremental mixed-mode result over speculative generated code. Keep `definition()` for any remaining DSL elements; delete it only when it is empty and the resolved module name is preserved by `@ExpoModule`.
3. Apply the migration
Migrate one semantic group at a time: module naming, functions, properties/constants, events, shared objects, then records. Keep the diff narrow.
Follow these invariants:
- Preserve every existing JS-visible name explicitly when Swift naming rules or macro defaults differ.
- Keep original optional/default behavior. An optional 1.0 record field must not become required merely because 2.0 can express required fields.
- Keep same-JS-name overloads on the DSL. The plugin does not dispatch overloads, and it reports two `@JS` members with one JS name on one object as a build error.
- Preserve async threading behavior. A 1.0 `AsyncFunction` body ran off the JS thread; a 2.0 `async` `@JS` member starts on it and leaves only at the first `await`. A body that never awaits therefore blocks the JS thread, with no change to the JS signature. Audit what runs before the first `await`, and never leave blocking I/O on the JS actor. Fix with `@JS(.concurrent)`, return a `Task.detached` from a synchronous function, or restructure onto Swift Concurrency or a continuation. A plain `Task { }` in a `@JS` function that is not `noniso
Read more
name: expo-migrate-module description: Migrate an existing Apple/Swift Expo native module from the Expo Modules API 1.0 definition DSL to the 2.0 macro API (sometimes called v2) while preserving its JavaScript and TypeScript contract. Use when converting or incrementally adopting @ExpoModule, @JS, @Event, @SharedObject, or @Record in an existing module. Do not use for creating a new module, general Expo SDK upgrades, or Android/Kotlin migrations. license: MIT
Migrate an Expo Module
Migrate the Swift side of an existing Expo module without changing its observable JS API. Treat the current JS/TypeScript surface and tests as the compatibility contract. Leave Kotlin on the 1.0 DSL unless the user explicitly expands the task.
Prerequisite
Use `expo` `58.0.21` or newer. Earlier `58.0.x` releases lack tuple conversion and the `swift:check` and `swift:expand` commands of `expo-modules-cli`. Older ones also lack `lazy` `@JS` properties, `SharedRef<T>` subclasses in `@SharedObject`, static shared-object members, and `.withAsync`, and the oldest lack closure arguments and free-form `Any` return values. SDK 57 also lacks `.concurrent`, `@Union`, and module auto-discovery. Do not target either. Before editing, check the installed version (`npm ls expo`, or the lockfile). If it is older, stop and tell the user to upgrade first.
Check the example app's own dependencies too, not only the module root. The example app is the integration surface you build and launch in step 4, so it needs the same floor.
This is a floor, not a guarantee: the macro and core surface changed in almost every `58.0.x` patch release, so step 2 must still verify the installed source.
The macros are still **experimental and undocumented**: SDK 58 has no page about them in the Expo docs, and the API can still change under you. Say this to the user before a large migration, and prefer incremental mixed mode over converting a module wholesale.
References
- Read `references/migration-map.md` before changing source. It contains the 1.0-to-2.0 mappings, semantic traps, and mixed-mode rules.
- Read `references/example.md` for a full before/after walkthrough of one module through mixed mode to a complete migration. Consult it when you need to see how the per-member rules compose.
- Read `references/compatibility.md` when the checked-out `expo-modules-core` version or branch is not known to support every requested macro. It explains how to verify the actual compile-time and runtime surface instead of guessing from a version number, gives the SDK 58 status of each capability, and shows how to run the module scanners and the `expo-modules-cli` checks.
Workflow
1. Establish the contract
Inspect repository instructions and the worktree before editing. Locate the Swift module classes, records, shared objects, native views, JS/TS bindings, tests, example app, podspec, `expo-module.config.json`, and installed or checked-out `expo-modules-core`.
Inventory every exported item before rewriting it:
- module and shared-object JS names
- function names, arity, labels, defaults, nullability, sync/async behavior, errors, and queue/thread semantics (note which `AsyncFunction` bodies do blocking or long-running work, any `.runOnQueue(...)`, and sync/async pairs that share a body, such as `readSync` and `read`)
- property names, mutability, and constant caching behavior
- event wire names and payload shapes, including events sent with no body (1.0 `sendEvent("name")` sends `{}`)
- record field names, defaults, requiredness, and nullability
- shared-object constructors and instance/static placement (`Function` vs `StaticFunction`/`StaticAsyncFunction`)
- lifecycle hooks and views
Use the TypeScript declarations and JS call sites to resolve ambiguity. Do not silently "improve" requiredness, rename an event, or change sync behavior during a syntax migration.
2. Verify the available 2.0 surface
Inspect the macro declarations and matching core hooks in the dependency actually used by the target. Do not assume that all items in the 2.0 design are present because one macro compiles.
Classify each 1.0 item as:
- **Migrate:** both its macro and required core runtime support exist.
- **Keep in DSL:** mixed mode preserves it safely, or 2.0 lacks an equivalent.
- **Blocked:** migration would alter the JS contract or requires unavailable runtime support.
A DSL member that takes or returns `JavaScriptValue` does not compile on SDK 58, so it cannot stay in the DSL. Migrate it to `@JS` with a `borrowing JavaScriptValue` parameter, as described in `references/migration-map.md`.
Prefer an incremental mixed-mode result over speculative generated code. Keep `definition()` for any remaining DSL elements; delete it only when it is empty and the resolved module name is preserved by `@ExpoModule`.
3. Apply the migration
Migrate one semantic group at a time: module naming, functions, properties/constants, events, shared objects, then records. Keep the diff narrow.
Follow these invariants:
- Preserve every existing JS-visible name explicitly when Swift naming rules or macro defaults differ.
- Keep original optional/default behavior. An optional 1.0 record field must not become required merely because 2.0 can express required fields.
- Keep same-JS-name overloads on the DSL. The plugin does not dispatch overloads, and it reports two `@JS` members with one JS name on one object as a build error.
- Preserve async threading behavior. A 1.0 `AsyncFunction` body ran off the JS thread; a 2.0 `async` `@JS` member starts on it and leaves only at the first `await`. A body that never awaits therefore blocks the JS thread, with no change to the JS signature. Audit what runs before the first `await`, and never leave blocking I/O on the JS actor. Fix with `@JS(.concurrent)`, return a `Task.detached` from a synchronous function, or restructure onto Swift Concurrency or a continuation. A plain `Task { }` in a `@JS` function that is not `noniso
A collection of AI agent skills for working with Expo projects and Expo Application Services
Other skills on skills.
More skills from other plugins.
brainstorming
You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or…
ask-matt
Ask which skill or flow fits your situation. A router over the skills in this repo.
Questions about expo-migrate-module.
- Which coding agents can use expo-migrate-module?
- expo-migrate-module installs in Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, OpenCode and Windsurf with the skills CLI, using each agent's flag shown on this page. Flowy's auto-invocation, which fires a skill when your prompt matches, runs in Claude Code only.

