Typescript moduleSuffixes with React Native
Aug 16, 2023 // 5 min readFor the longest time, there was no easy way for typescript to mirror the react-native compiler's pattern of prioritizing files based on extension (.native
, .web
). This meant that types in a component that needed to use that strategy were never 100% what the author wanted (or at least what I wanted). So now that this exists, let's go through an example to show: what I'm talking about and how powerful it can be!
Identifying the problem
Let's first go through an example of the problem so that we can visualize what problem moduleSuffixes
is solving.
First the file structure:
Button
---index.ts
---index.native.ts
ButtonGroup
---index.ts
Inside of ButtonGroup/index.ts
we'd want to import the Button so we could use it, like this:
import { Button } from "./Button":
When following this pattern react-native will prioritize whichever file is most relevant to the user, so if you're importing from a react-native project, Button/index.native.ts
would be the Button that displays. If you're on web and do the same import Button/index.ts
would be the Button that displays.
Historically there was no good way of letting typescript know about this. So you'd have to define types that would be used across any platform that the component supports. If a type was only applicable to one platform you'd need runtime logic to handle notifying the engineer that this was the case. You would run into cases where a Button
component that was built for cross platform usage would be able to be passed an onPress
prop and an onClick
prop without a type error, which means the engineer wouldn't get any feedback for if their prop was valid for the platform they were building for.
Enter 2023
Now we have modulesSuffixes
. Using this tsconfig
option combined with some smart build output structuring lets typescript utilize the correct for the platform you're building for. Here's an example, again starting with file structure.
react-native-project
---ButtonGroup.ts
---tsconfig.json
web-project
---ButtonGroup.ts
---tsconfig.json
shared-ui-library
---dist
------index.js
------index.d.ts
------index.native.js
------index.native.d.ts
---src
------index.ts
------index.native.ts
------Button
---------index.ts
---------Button.tsx
---------Button.native.tsx
---------Button.types.ts
---tsconfig.json
To summarize, we have a react-native-project
, and a web-project
that are utilizing a button from a shared-ui-library
package.
Setting up tsconfig.json
files
Here are what the tsconfig.json
files will look like across the 2 different projects that would be utilizing the shared Button
component:
react-native-project/tsconfig.json
Notice that we tell typescript to prioritize file extensions containing .native
as that is the first suffix in the array. The empty string (""
) signifies no extension, so this will then look for any regular .js/.ts/.d.ts
file.
{
"compilerOptions":{
"moduleSuffixes": [".native", ""],
}
}
web-project/tsconfig.json
Similar to the above, because this is a web project we don't care about suffixes (or we could and prioritize the .web
extension). So for this example there is no suffix to prioritize.
{
"compilerOptions":{
"moduleSuffixes": [""],
}
}
shared-ui-library/tsconfig.json
This configuration is primarily to give the tsc
build command some direction. You can definitely tailor this file differently to address your own needs.
{
"compilerOptions":{
"outDir": "./dist",
"module": "commonjs",
"skipLibCheck": false,
"noEmit": false
}
}
Setting up the shared Button component
To set up the Button to work with this configuration is pretty simple, but missing a step could mean this won't work, so make sure you follow every detail I'm about to explain.
First, here's a reminder of the file structure that we're working with for this section:
shared-ui-library
---dist
------index.js
------index.d.ts
------index.native.js
------index.native.d.ts
---src
------index.ts
------index.native.ts
------Button
---------index.ts
---------Button.tsx
---------Button.native.tsx
---------Button.types.ts
---tsconfig.json
Inside of the /src
directory it's important that we have two entry files, so that way out output of the build in the /dist
directory contains files with and without the .native
extension.
Here's the contents of each file (they're the same):
src/index.ts
export { Button } from './Button'
src/index.native.ts
export { Button } from './Button'
This tells the compiler to look into the Button folder for the Button component. Because the export is extension-less, it will prioritize the correct extension based on the platform.
Inside of the /Button
directory you'll see a similar pattern:
src/Button/index.ts
export { Button } from './Button'
Now here's where a bit of the magic happens so that you can have different types depending on the platform. We're going to create a typescript generic type that can infer the platform being passed in, then surface those types when utilizing the Button
component. We'll use this type in both of the remaining Button
files.
src/Button/Button.types.ts
// Note: these types are simple for the example, you'd want to pull in correct types like HTMLButtonElement and Pressable props when doing this for real.
type NativeProps = {
onPress: () => void
}
type WebProps = {
onClick: () => void
}
export type ButtonProps<Platform> = Platform extends infer P
? P extends "web"
? WebProps
: P extends "native"
? NativeProps
: never
:never
Creating the Button
components for each platform is pretty straightforward now that we have the ButtonProps
type doing the heaving lifting.
src/Button/Button.tsx
import { type ButtonProps } from './Button.types'
export function Button ({...props}: ButtonProps<'web'>){
return (
<button {...props}/>
)
}
src/Button/Button.native.tsx
import { type ButtonProps } from './Button.types'
import { Pressable } from 'react-native'
export function Button ({...props}: ButtonProps<'native'>){
return (
<Pressable {...props}/>
)
}
Setting up the build process
Now that we have all the pieces in place, it's time to build the button. Here's some helpful snippets from the package.json
file in the shared-ui-library
. The important pieces are that main
, types
, and react-native
are pointing to the right places.
shared-ui-library/package.json
{
"name": "shared-ui-library",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"react-native": "src/index.native.ts",
"scripts":{
"build": "tsc"
}
}
Your /dist
folder should look roughly like this:
dist
index.d.ts
index.js
index.native.d.ts
index.native.js
---Button
------Button.d.ts
------Button.jsx
------Button.native.d.ts
------Button.jsx
------Button.types.d.ts
------Button.types.js
------index.d.ts
------index.js
It's important that you have both extension-less and .native
files in this folder so that typescript will be able to decipher which types belong to which platform.
All done!
With this sort of setup you now have the ability to have mutually exclusive types for each platform and also the ability to share them as you please.
So now, when utilizing the Button
component in the web-project
and you pass it the onPress
prop, a type error will throw since we defined in our ButtonProps
type that web
should be expecting onClick
.
import { Button } from 'shared-ui-library'
const ButtonGroup = () => {
return (
// Throws: Type "onPress" does not exist on type....
<Button onPress={handleOnPress}>Click me</Button>
)
}
In conclusion
On the surface, this seems like such a trivial thing that should have been built into typescript awhile ago, but now that it's here (thanks Adam Foxman), it's a huge unlock when it comes to type safety in cross platform applications in React and React Native. I will even be adapting this pattern to be used in the cross platform library I'm working on.