React Native has long supported platform-specific files such as Button.native.tsx, Button.ios.tsx, and Button.android.tsx. An extensionless import lets Metro choose the right file at runtime:
import { Button } from "./Button";
Before TypeScript 4.7, the compiler could not mirror that lookup order without extra configuration or duplicated public types. TypeScript might read Button.tsx while Metro loaded Button.native.tsx, which meant the editor could accept props that did not exist in the running app.
The moduleSuffixes compiler option lets each target describe the same lookup order as its bundler.
The mismatch
Consider a shared folder with separate web and native buttons:
Button.tsx
Button.native.tsx
ButtonGroup.tsx
The web button accepts onClick:
type WebButtonProps = {
onClick: () => void;
children: React.ReactNode;
};
export function Button({ onClick, children }: WebButtonProps) {
return <button onClick={onClick}>{children}</button>;
}
The native button accepts onPress:
import { Pressable, Text } from "react-native";
type NativeButtonProps = {
onPress: () => void;
children: string;
};
export function Button({ onPress, children }: NativeButtonProps) {
return (
<Pressable onPress={onPress}>
<Text>{children}</Text>
</Pressable>
);
}
ButtonGroup.tsx should not need to include a platform extension in its import:
import { Button } from "./Button";
Metro already knows which implementation to load. TypeScript needs matching instructions so it checks the correct props.
Configure each target
Use a separate configuration for each platform so TypeScript and Metro prefer the same file. The iOS project can extend the shared configuration with .ios first:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"moduleSuffixes": [".ios", ".native", ""]
}
}
The Android project needs its own order:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"moduleSuffixes": [".android", ".native", ""]
}
}
A configuration that checks shared native code without choosing iOS or Android can use [".native", ""].
The empty string is required. It tells TypeScript to fall back to the file without a platform suffix.
The web project can prefer an explicit web implementation and then the default file:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"moduleSuffixes": [".web", ""]
}
}
With those configurations, TypeScript resolves the same extensionless import differently:
iOS: ./Button.ios.tsx -> ./Button.native.tsx -> ./Button.tsx
Android: ./Button.android.tsx -> ./Button.native.tsx -> ./Button.tsx
Web: ./Button.web.tsx -> ./Button.tsx
Now onClick fails type checking in both native projects, while onPress fails in the web project.
Check the resolution
When the result is surprising, ask TypeScript which file it selected:
tsc --project tsconfig.ios.json --traceResolution
tsc --project tsconfig.android.json --traceResolution
The trace shows every suffix TypeScript tried and the file it accepted. This catches two common mistakes: leaving "" out of the list and running the editor or build against the wrong tsconfig.
The compiler and runtime still need to agree. moduleSuffixes changes TypeScript's lookup. It does not configure Metro, Vite, webpack, Jest, or another bundler for you.
Published packages need another decision
moduleSuffixes works well when the platform files are visible to the consuming TypeScript project. Publishing a package adds another layer because the package must expose the correct JavaScript and declaration files too.
Modern Metro supports the exports field and the react-native condition. A published library can use conditional exports to point native and default consumers at separate build outputs. Metro does not apply platform extensions after it has matched an exact export target, so the package should publish explicit targets rather than expect Metro to add .native for it.
That package setup depends on the build tool and module format. Verify all three parts together:
- The package emits JavaScript for each target.
- It emits matching declaration files.
- Its exports send each consumer to the matching pair.
For source shared inside an app or monorepo, moduleSuffixes may be all that is missing. For a published library, treat it as one part of module resolution rather than the entire packaging strategy.
The official TypeScript documentation lists the lookup behavior, and the React Native documentation covers both platform-specific files and package exports.