React Native and Expo: How to Architect Shared TypeScript Monorepos
· React Native · Expo · TypeScript · Monorepo · Architecture
Learn how to structure a cross-platform React Native and Expo monorepo using TypeScript path aliases, project references, and strict boundary enforcement.
获取更新
每当我发布新内容时,你会收到一条简短通知。你的电子邮件或浏览器订阅信息仅用于发送这些更新,并可随时取消订阅;无需账户或跟踪档案。
Architectural Overview of Cross-Platform Monorepos
Scaling a mobile application alongside a web dashboard often leads to duplicated business logic, divergent type definitions, and painful dependency management. By establishing a monorepo containing cross-platform React Native applications built with Expo, shared business logic packages, and design systems, engineering teams can maximize code reuse without sacrificing native performance or platform-specific abstractions. However, maintaining strict architectural boundaries across packages is essential to prevent circular dependencies and messy build cascades.
A typical production-grade layout separates applications from packages. The apps/ directory houses deployable targets such as your Expo mobile client and Next.js web dashboard, while the packages/ directory contains platform-agnostic domain logic, UI component libraries, and utility modules. Managing this layout effectively requires careful configuration of TypeScript project references, package manager workspaces, and bundling tools like Metro and Webpack.
Workspace Configuration and Package Management
Package managers like pnpm or Yarn provide robust workspace support that enforces deterministic dependency resolution and prevents phantom dependencies. Using pnpm workspaces, your root pnpm-workspace.yaml explicitly defines where packages reside:
packages:
- 'apps/*'
- 'packages/*'Each internal package must define its own package.json with a unique name scope, such as @repo/domain or @repo/ui. When referencing internal packages within an application, avoid relative paths spanning outside the app boundary. Instead, consume them as standard workspace dependencies:
{
"dependencies": {
"@repo/domain": "workspace:*"
}
}This approach ensures that your bundler resolves the package via its entry point rather than arbitrary relative traversal, matching the consumption pattern of third-party registry packages. It also simplifies local linking and prepares your codebase for eventual extraction into private package registries if your organizational scaling requires it.
TypeScript Project References and Path Aliases
Shared packages must compile cleanly and export explicit declaration files to provide accurate type inference to consuming applications. TypeScript project references enable incremental builds and strict separation of compilation units.
In your root tsconfig.json, configure composite project references:
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"strict": true
}
}For individual packages like packages/domain, the tsconfig.json should reference its composite status and define explicit include and exclude globs:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}Consuming these packages inside an Expo application requires aligning Metro bundler resolution with TypeScript path aliases. Because Metro does not natively parse TypeScript paths mapping out of the box without configuration plugins, use tools like babel-plugin-module-resolver or @expo/metro-config to map workspace imports correctly during bundling.
Handling Platform-Specific Code Implementations
A common challenge in cross-platform codebases is isolating platform-specific APIs—such as secure storage, haptics, or push notifications—while keeping business logic platform-agnostic. Relying on conditional runtime checks like Platform.OS === 'ios' inside shared domain logic pollutes packages with unnecessary native dependencies and complicates testing.
Instead, leverage dependency injection or interface abstraction. Define a TypeScript interface inside your shared domain package:
export interface StorageAdapter {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
}Implement this interface inside your Expo application using Expo's secure storage module:
import * as SecureStore from 'expo-secure-store';
import { StorageAdapter } from '@repo/domain';
export class ExpoSecureStoreAdapter implements StorageAdapter {
async getItem(key: string): Promise<string | null> {
return SecureStore.getItemAsync(key);
}
async setItem(key: string, value: string): Promise<void> {
await SecureStore.setItemAsync(key, value);
}
}Pass the concrete implementation down to your domain layer during app initialization. This pattern keeps your shared packages free of mobile-specific dependencies, allowing them to be tested independently in Node-based test runners without mocking native modules.
Build Optimization and Caching Strategies
As monorepos scale, CI/CD build times degrade rapidly if every package compiles from scratch on every commit. Adopting a build orchestration tool like Turborepo or Nx transforms your pipeline by introducing content-aware task hashing and remote caching.
Configure your turbo.json to define task dependencies and output caching rules:
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".expo/**"]
},
"lint": {},
"test": {
"dependsOn": ["^build"]
}
}
}By declaring that a package's build task depends on the build task of its dependencies (^build), the orchestrator guarantees correct topological execution order. When integrated with a remote cache backend, unchanged packages bypass execution entirely, reducing pull request verification times from minutes to seconds.
Verification, Testing, and Edge Cases
Maintaining a healthy monorepo requires rigorous automated verification. Enforce architectural boundaries by integrating ESLint plugins such as eslint-plugin-import or @nx/enforce-module-boundaries to prevent applications from importing internal modules from other applications, or domain packages from depending on UI packages.
Unit tests for shared domain packages should run using Jest or Vitest in a pure Node environment, avoiding any requirement for a React Native runtime. For end-to-end testing of your Expo application, pair Detox or Maestro with your build pipelines to run integration suites against native binaries built from the monorepo workspace.
Watch out for subtle hoisting issues where package managers hoist shared dependencies to the root workspace node_modules, occasionally causing version mismatches in React Native's native bridge modules. Pin critical peer dependencies like react, react-native, and expo across all workspace packages using catalog features or strict root resolutions to ensure a single React runtime exists in memory at execution time.
