Working with native code in React Native usually involves creating separate packages and manually maintaining TypeScript interfaces. Expo SDK 56 changes this by letting you write Swift and Kotlin files right alongside your React components and automatically generating the TypeScript types for you.
Writing Expo modules traditionally meant dealing with package boilerplate and keeping multiple interfaces in sync. You'd create a module as a standalone package, then manually maintain TypeScript interfaces that matched your native Swift and Kotlin code. This workflow worked but added friction.
SDK 56 introduces inline modules and the expo-type-information package to remove these pain points. You can now write native modules directly in your project structure and generate matching TypeScript types automatically.
Writing native code next to your components
Inline modules let you place Swift and Kotlin files anywhere in your project structure. Need a custom native view? Create NativeView.kt and NativeView.swift files right next to your App.tsx and write your view there.
Setting up inline modules is simple. In your app configuration file, specify watchedDirectories - the directories containing your inline modules. After running npx expo prebuild to sync the native projects, you're ready to go.
{
"expo": {
"experiments": {
"inlineModules": {
"watchedDirectories": ["app"]
}
}
}
}
With "app" in your watchedDirectories, you can create Swift and Kotlin files anywhere within the app directory or its subdirectories. Open app/nested/InlineModule.swift and write your Expo module:
internal import ExpoModulesCore
class InlineModule: Module {
public func definition() -> ModuleDefinition {
Constant("Hello") {
return "Hello iOS inline modules!"
}
}
}
Once written, access it from JavaScript using requireNativeModule('InlineModule'). For views, use requireNativeView('InlineModule').
Automatic TypeScript type generation
The expo-type-information package parses your Swift modules and generates matching TypeScript types automatically. This eliminates the manual work of maintaining type interfaces.
The package includes a CLI tool with commands designed for inline modules.
The inline-modules-interface command finds all Swift inline modules in your project and generates two TypeScript files for each one. After running the command, you'll see these files appear next to your Swift file:
The Generated File ([ModuleName].generated.ts) contains all type information about the module, including function declarations, constants, classes, and views. This file gets overwritten every time you run the command.
// InlineModule.generated.ts
/*Automatically generated by expo-type-information.*/
import { ViewProps } from 'react-native';
import { NativeModule } from 'expo';
export declare class InlineModuleNativeModuleType extends NativeModule {
readonly Hello: string;
}
The Stable File ([ModuleName].tsx) re-exports the module interface and provides a default export for the main view if one exists. You can edit this file and it won't be overwritten.
// InlineModule.tsx
// File hash: 8dfc86f5416afbe08cc1ee581c850fc9cec446479211d85501d9a5e2d24cc534
import { InlineModuleNativeModuleType } from './InlineModule.generated';
import { requireNativeModule, requireNativeView } from 'expo';
const InlineModule: InlineModuleNativeModuleType =
requireNativeModule<InlineModuleNativeModuleType>('InlineModule');
export const Hello: string = InlineModule.Hello;
This split lets you tweak imperfect generated output while still allowing core declarations to be regenerated when you update the native side.
Current limitations
File naming requires that an inline module's name exactly matches its file name. Module names must be globally unique since the name identifies the module in the global object. You can't have both app/InlineView.swift and src/InlineView.swift in the same project.
Type generation currently only works for Swift modules and on macOS.
When types can't be resolved
Sometimes the tool can't resolve types of native module declarations. This happens because of SourceKitten limitations and our decision to parse only provided files instead of doing full compilation. When we can't resolve a Swift type, we generate an unknown type in TypeScript.
Common scenarios include nested declarations where SourceKitten has trouble with deeply nested closures, making it hard to find types inside a Class. Consider this ExpoBlob module:
// ExpoBlob.swift
import Foundation
import ExpoModulesCore
public class ExpoBlob: Module {
public func definition() -> ModuleDefinition {
Name("ExpoBlob")
Class(Blob.self) {
Constructor { (blobParts: [EitherOfThree<String, Blob, TypedArray>]?, options: BlobOptions?) in
let endings = options?.endings ?? .transparent
let blobPartsProcessed = processBlobParts(blobParts, endings: endings)
return Blob(blobParts: blobPartsProcessed, options: options ?? BlobOptions())
}
Property("size") { (blob: Blob) in
blob.size
}
Property("type") { (blob: Blob) in
blob.type
}
Function("slice") { (blob: Blob, start: Int?, end: Int?, contentType: String?) in
let blobSize = blob.size
let safeStart = start ?? 0
let safeEnd = end ?? blobSize
let relativeStart = safeStart < 0 ? max(blobSize + safeStart, 0) : min(safeStart, blobSize)
let relativeEnd = safeEnd < 0 ? max(blobSize + safeEnd, 0) : min(safeEnd, blobSize)
return blob.slice(start: relativeStart, end: relativeEnd, contentType: contentType ?? "")
}
AsyncFunction("text") { (blob: Blob) async -> String in
await blob.text()
}
AsyncFunction("bytes") { (blob: Blob) async -> Data in
let bytes = await blob.bytes()
return Data(bytes)
}
}
}
}
// ExpoBlob.generated.ts
/*Automatically generated by expo-type-information.*/
import { ViewProps } from 'react-native';
import { NativeModule } from 'expo';
// These types haven't been defined in provided file(s).
export type Data = unknown;
export type TypedArray = unknown;
export type BlobOptions = {
type: string;
endings: EndingType;
};
export enum EndingType {
transparent = 'transparent',
native = 'native'
}
export enum BlobPart {
string = 'string',
blob = 'blob',
data = 'data'
}
export declare class Blob {
slice(
blob: Blob,
start: number | undefined,
end: number | undefined,
contentType: string | undefined
): unknown /*The type couldn't be resolved automatically.*/;
text(blob: Blob): Promise<string>;
bytes(blob: Blob): Promise<Data>;
readonly size: unknown /*The type couldn't be resolved automatically.*/;
readonly type: unknown /*The type couldn't be resolved automatically.*/;
constructor(
blobParts: (string | Blob | TypedArray)[] | undefined,
options: BlobOptions | undefined
);
}
export declare class ExpoBlobNativeModuleType extends NativeModule {
Blob: typeof Blob;
}
Notice how the return types of slice, type, and size haven't been resolved. You can often fix this by manually annotating the return type of the closure in Swift.
Imported declarations also cause issues since we only parse provided files and ignore imports. If a function or type from outside influences a return value, the tool may not resolve it.
The tool struggles with return types if you omit the return keyword and don't explicitly annotate the closure. To avoid this, annotate DSL declarations or include the return keyword. Try using --type-inference PREPROCESS_AND_INFERENCE in the CLI for better return type inference.
How it works under the hood
Let's examine what happens when you use inline modules. Suppose you've created a module at app/nested/InlineModule.swift and set watchedDirectories to include the app folder.
{
"expo": {
"experiments": {
"inlineModules": {
"watchedDirectories": ["app"]
}
}
}
}
Prebuild process
Running npx expo prebuild updates your native projects in two ways:
The Xcode project gets updated so the app folder becomes a file system synchronized group. This makes all files in the folder show up in the Xcode editor and get automatically included in the iOS build.
Project properties for both iOS and Android get updated with your watchedDirectories. On Android, this is essential for adding files to Android Studio. These properties are used later during autolinking.
// Podfile.properties.json
{
"expo.jsEngine": "hermes",
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
"expo.inlineModules.watchedDirectories": "[\"app\"]"
}
// gradle.properties
# ...
expo.inlineModules.watchedDirectories=["app"]
Android project updates
On Android, the project updates during the Gradle configuration phase. This happens when you click Sync Project with Gradle Files in Android Studio or automatically before building the app.
During this phase, a folder structure gets created that mirrors your watchedDirectories. Symlinks are created to the Kotlin files inside those directory trees.
This mirror structure ensures native files get compiled and are visible in Android Studio, while other project files are ignored. JavaScript and TypeScript files won't be indexed by Android Studio since they're not in this mirror directory.
Module registration
All Expo modules live on a global object exposed through native module providers. During build on both iOS and Android, a module provider class gets generated containing references to all regular Expo modules and your inline modules. When you call requireNativeModule('InlineModule') in JavaScript, it accesses this global object.
Type generation internals
Type generation works by parsing the native module's structured declaration and generating its TypeScript interface directly from native code. The expo-type-information package has four main parts:
- Swift parser
- Abstraction over module types
- TypeScript code generator
- CLI tool
They work together to automate writing TypeScript interfaces for your modules.
Bridging type systems
When defining a module in the DSL, you provide the native interface with functions, constants, classes, and views - all strictly typed in Swift's type system.
When interfacing with the module from TypeScript, you're working with JavaScript objects under TypeScript's type system. This differs from Swift or Kotlin, and there's no strict one-to-one mapping between them. The conversions between JavaScript and Swift aren't always obvious.
Expo provides converters for many types, but multiple TypeScript constructs sometimes convert to the same Swift type.
For example, when working with Swift's UIColor, Expo can convert several JavaScript objects: color strings ('red'), hex strings (#00ffaa00), or hex numbers (0xff66dd00). All of these can be type-annotated differently in TypeScript - string, number and ColorValue (from react-native) all convert to UIColor.
Currently we only support mapping basic types (number, string, boolean, etc.). Check the exact list in the reference. We'll continue updating the package with additional type mappings based on converters available in expo-modules-core.
Library components
The expo-type-information package consists of several components working together.
Swift file parsing
We use SourceKitten to parse Swift files. SourceKitten provides structured information about the whole code, letting us parse the Swift DSL, enums, and structs.
Consider the Hello constant declaration from InlineModule.swift:
Constant("Hello") {
return "Hello iOS inline modules!"
}
That Swift declaration corresponds to this SourceKitten output:
{
"key.bodylength": 56,
"key.bodyoffset": 124,
"key.kind": "source.lang.swift.expr.call",
"key.length": 66,
"key.name": "Constant",
"key.namelength": 8,
"key.nameoffset": 115,
"key.offset": 115,
"key.substructure": [
{
"key.bodylength": 7,
"key.bodyoffset": 124,
"key.kind": "source.lang.swift.expr.argument",
"key.length": 7,
"key.offset": 124
},
{
"key.bodylength": 48,
"key.bodyoffset": 133,
"key.kind": "source.lang.swift.expr.argument",
"key.length": 48,
"key.offset": 133,
"key.substructure": [
{
"key.bodylength": 46,
"key.bodyoffset": 134,
"key.kind": "source.lang.swift.expr.closure",
"key.length": 48,
"key.offset": 133,
"key.substructure": [
{
"key.bodylength": 46,
"key.bodyoffset": 134,
"key.kind": "source.lang.swift.stmt.brace",
"key.length": 48,
"key.offset": 133
}
]
}
]
}
]
}
SourceKitten lets us parse just a single file. This saves time since it doesn't have to compile the whole Xcode project, which is essential when you want to constantly regenerate TypeScript interfaces. But not having access to the whole project means types and functions defined in other files can't be resolved.
Type information abstraction
An abstraction layer defines what type information is relevant for Expo modules. The abstraction is agnostic to the underlying native language, so we can add Kotlin support in the future. It's close to the TypeScript type system since it generates TS declarations. Our SourceKitten-based parser outputs this abstraction.
/**
* `FileTypeInformation` object abstracts over type related information in a file.
* The abstraction is closely related to Typescript and expo NativeModules (both to be independent of the actual native side
* and to give accurate information about what and how we can use the given module).
* @header TypeInfoTypes
*/
export type FileTypeInformation = {
/**
* @field Set of all type identifiers declared and used in the file.
*/
usedTypeIdentifiers: Set<string>;
/**
* @field Set of all type identifiers declared in the file.
*/
declaredTypeIdentifiers: Set<string>;
/**
* @field For parametrized types it is the maximum number of parameters this type is used with.
* This map is useful if we want to infer how many parameters a type declared in other file has.
*
* For example if `Set<string>` exists in a file then inferredTypeParametersCount['Set'] == 1.
* If `Map<number, string>` exists then inferredTypeParametersCount['Map'] == 2.
* If you use both `SomeParametrizedType<Type1, Type2>` and `SomeParametrizedType<Type3>` then inferredTypeParametersCount['SomeParametrizedType'] == 2.
*/
inferredTypeParametersCount: Map<string, number>;
/**
* @field Maps string identifier to the appropriate declaration object. For now only enum and records identifiers are mapped.
*/
typeIdentifierDefinitionMap: TypeIdentifierDefinitionMap;
/**
* @field Array of all module classes declared in the given file.
*/
moduleClasses: ModuleClassDeclaration[];
/**
* @field Array of all record classes declared in the given file.
*/
records: RecordType[];
/**
* @field Array of all enums declared in the given file.
*/
enums: EnumType[];
};
TypeScript emission
With Expo Modules types abstracted, we generate a TypeScript Abstract Syntax Tree (AST). We build this using the Compiler API with custom wrappers that handle different declaration types - imports, enums, classes, functions, types, interfaces. Once the AST is complete, we format the generated TypeScript with Prettier.
CLI interface
The CLI tool sits on top of everything. It exposes commands to work with regular modules, inline modules, and to debug the functions from previous steps.
Next steps
Check out the tutorials for inline modules and type generation, plus the reference pages for inline modules and the expo-type-information package.
Both features are experimental, so your feedback matters as we continue developing them. File an issue or create a pull request on GitHub, or share your thoughts on Twitter.
This post is based on content from the Expo blog. Follow @expo for more React Native content.





Top comments (0)