Sharing TypeScript Code Between Web and React Native

We’re building a hybrid mobile app out of an existing web app. The front end will run out of a webview, and we’re adapting the back end to run on-device in React Native.

One of the challenges we encountered was how to share code between our existing monorepo built with webpack and our new React Native app (which bundles with Metro).

Though it’s all TypeScript, this wasn’t super straightforward. In this post, I’ll describe how we handled it.

Plan A

Let’s call the existing web app web-monorepo and the new React Native app offline-rn-app.

We initially attempted to reference sources directly, so that from offline-rn-app, you could import { foo } from "web-monorepo/bar";. We encountered a lot of friction with this approach. For example:

  • Though React Native now has TypeScript support out of the box (😃), it uses Babel’s built-in TypeScript handling, which has some known limitations, and it couldn’t handle some patterns in our existing code (😩).
  • Our application code uses a handful of webpack-specific features, like requiring .graphql or .yml files with special loaders, and importing whole directories of files with require.context.

With enough time and research, it would probably be possible to work around issues like these, but it felt like we were swimming upstream. As we struggled with the particulars of our webpack configuration, we thought, “Why not let webpack do its thing, and then consume its output?”

Plan B

We already had two webpack entry points in the web-monorepo project: one for the React front end, and one for the Express backend. Our idea was is to add a third, producing a library that can be consumed by the React Native app.

This way, we get the real TypeScript compiler plus all of the webpack loaders, and a bunch of dead code can get shaken out.

Spoiler: It worked.

Details

Here are the important parts of the configuration we’ve settled on.

package.json

The first step toward building an installable library out of our existing Node project was to drop a few additions in its package.json:


// package.json
"main": "lib/index.js",
"types": "lib/entry/mobile-server.d.ts",
"files": [
  "lib/*"
],
"scripts": {
  "build:library": "webpack --config ./webpack/library.config.js"
}

The “main” and “types” fields are used by the consuming app upon installing our package. The “files” globs specify what we want consuming apps to get when they install the library. Lastly, the “build:library” convenience script leads us to our new webpack config.

Webpack and tsconfig

Once we declared the files we intend to distribute, it was time to build them. Here’s the new webpack config:


// webpack/library.config.js
const path = require("path");

module.exports = {
  mode: "development",
  entry: {
    "mobile-server": "./entry/mobile-server.ts"
  },
  devtool: "source-map",
  output: {
    path: path.resolve(__dirname, "../lib"),
    filename: "index.js",
    library: "my-lib",
    libraryTarget: "umd"
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: [
          {
            loader: "ts-loader",
            options: {
              configFile: "tsconfig.library.json"
            }
          }
        ]
      }
    ]
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js"],
    modules: [path.resolve(__dirname, "../modules"), "node_modules"]
  },
  externals: ["cheerio", "config"]
};

From an input of entry/mobile-server.ts, this produces the lib/index.js that package.json’s main is expecting and a source map to go with it.

While most third-party code is bundled in, we can add externals for packages that we want the consuming app to provide. (More on this later.)

We’ve also supplied a custom tsconfig:


// tsconfig.library.json
{
  "extends": "./tsconfig",
  "compilerOptions": {
    "module": "es6",
    "target": "es5",
    "allowJs": false,
    "noEmit": false,
    "declaration": true,
    "declarationMap": true,
    "lib": [],
    "outDir": "lib",
    "resolveJsonModule": true
  },
  "include": ["entry/mobile-server.ts"],
  "exclude": ["node_modules", "dist", "lib"]
}

There’s nothing too interesting here except for that empty lib list, which prevents the library build from inadvertently using APIs that aren’t available in React Native, like browser DOM or Node.js filesystem access.

With these in place, we can now yarn build:library to produce lib/.

Packaging

We don’t intend to publish our new library to a package repository, so we’ll need to reference it via one of the other patterns you can yarn add, like a file path or a GitHub repo.

After a little experimentation, we settled on producing a tarball with yarn pack. This makes for a nice single artifact to share between jobs in our CircleCI workflow.

Consuming the library

From offline-rn-app, we reference the tarball like this:


// package.json
"my-lib": "../web-monorepo/my-lib-1.0.0.tgz"

On the React Native side, using this feels about like using any other third-party library.

Recall the “externals” specified in the webpack config above? Some of the code we’re sharing depends on libraries that aren’t quite compatible with React Native. We may eventually migrate away from them, but for now, we have a decent workaround.

On the library side, we externalize the problematic modules to make the consuming app deal with it.

In the React Native app, we deal with it by swapping in alternate implementations. To do this, we added babel-plugin-module-resolver, which allows you to alias modules arbitrarily:


// babel.config.js
module.exports = {
  presets: [
    "module:metro-react-native-babel-preset",
    "@babel/preset-typescript"
  ],
  plugins: [
    [
      require.resolve("babel-plugin-module-resolver"),
      {
        alias: {
          cheerio: "react-native-cheerio",
          config: "./src/mobile-config.ts"
        }
      }
    ]
  ]
};

..and voila! We have code from our Express server running in React Native.

Future Improvement: Editor Experience

One rough patch I hope to smooth out in the future is the editor experience when working in the monorepo. VS Code only knows about one of our tsconfigs. So when I’m editing foo.ts, I’ll get squiggles according one of my build targets, but I may introduce errors that I won’t see until next time I compile the other target from the command line.

Another tradeoff we made with the move to Plan B is that we can no longer F12 from offline-rn-app’s sources into web-monorepo’s; instead, when you go to definition across the boundary, you land on the library’s type definitions. Could source maps improve on this?

Conclusion

Our solution involves a couple of compromises and a fair amount of complexity, but overall, this approach is working well.

Have you shared code between browser, server, and mobile? How’d it go?