Skip to main content
Lucas Lois Portfolio

If you’re using React Native, there is a chance you’re using Platform-specific extensions. You’re almost surely doing so if you’re deploying to the web as well, via React Native Web. But if you’re not careful, Typescript might not actually be checking all of your code!

TL;DR

Have platform-specific tsconfig.{platform}.json files to match your platform-specific files so all of the possible import trees are validated. Keep you base tsconfig.json as usual.

// tsconfig.ios.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "moduleSuffixes": [".ios", ".native", ""]
  }
}

Run linting with the following scripts on your package.json

// package.json
{
  "scripts": {
    "lint:ts": "concurrently \"npm:lint:ts:*\"",
    "lint:ts:web": "tsc -p tsconfig.web.json",
    "lint:ts:ios": "tsc -p tsconfig.ios.json",
    "lint:ts:android": "tsc -p tsconfig.android.json"
  }
}

Typescript covers the basics

Let’s say you’re building the Storage layer of your app. On the mobile side, you have decided on MMKV as your storage solution, for which you write the file below. Simple wrapper on the getBoolean and set methods, with some better typing. All good so far!

// storage.ts
export const readBoolean = (key: string) => {
  return mmkv.getBoolean(key);
};

export const writeBoolean = (key: string, value: boolean) => {
  return mmkv.set(key, value);
};

Then we get to web, and here you’ll be using localStorage. You figure there’s no reason to bring in an external dependency, localStorage is good as is. You need to play a bit with the JSON parsing, but with everything working, you push your work.

// storage.web.ts
export const readBoolean = (key: string) => {
  return JSON.parse(localStorage.getItem(key));
};

export const writeBoolean = (key: string, value: boolean) => {
  localStorage.setItem(key, JSON.stringify(value));
};

Typescript is so happy!

Then come the changes

Some weeks go by, and one of your teammates now wants to work with numbers. Just knowing whether something is or isn’t is not enough, they need to now how much! Quickly, they go in, add the function, and get out.

Easy-peasy — CI’s happy, so we merge.

// storage.ts
export const readNumber = (key: string) => {
  return mmkv.getNumber(key);
};

export const writeNumber = (key: string, value: number) => {
  return mmkv.set(key, value);
};

We’re good, right?

Chaos

Everything broken 🔥.

This is Fine Meme

With the changes to read/write numbers merged in, along with changes to feature-level code to use it, the auto-deploy to web goes through successfully, but crashed the production site! What went wrong?

Our code fails because it is trying to use the writeNumber function it imported, but because we actually linked to storage.web.ts instead, there is no such function!

The solution is simple: add these functions to the storage.web.ts file. But, how do we prevent this from happening altogether? Why has Typescript not informed us of the issue, as it would when any other function you import is not actually defined.

Typescript import resolution and module suffixes

When Typescript crawls through your code, it is resolving imports, and validating all the types as it does. However, when import { } from './storage' comes up, Typescript cannot fork into "oh let’s consider .web on the one hand, and “base” or mobile on the other. It has to go with just one, and the base config is chosen over the web variant. Without further changes, you can see that Typescript will only ever validate the base files on your project.

How can you achieve complete, cross-platform, type-safety, you ask? By using a Compiler Option known as Module suffixes. With this option you can give Typescript an ordered list of possible suffixes to try as it resolves a module. If you pass in [".ios", ""], Typescript will try storage.ios.ts first, and only after that fails will it try storage.ts. This means we can set it to [".web", ""] so storage.web.ts gets picked up — but we would then lose checking storage.ts.

What we end up needing (because this configuration changes as we think of each platform we target) is three different tsconfig files, one for each. They can all extend a base tsconfig.json file, and override the moduleSuffixes option with the right value.

// tsconfig.web.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "moduleSuffixes": [".web", ""]
  }
}

Then you can make running the linter simpler by adding a couple helpful scripts, using the -p flag on tsc to select the right tsconfig file.

// package.json
{
  "scripts": {
    "lint:ts": "concurrently \"npm:lint:ts:*\"",
    "lint:ts:web": "tsc -p tsconfig.web.json",
    "lint:ts:ios": "tsc -p tsconfig.ios.json",
    "lint:ts:android": "tsc -p tsconfig.android.json"
  }
}