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 basetsconfig.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 🔥.
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"
}
}