shantanu.tech
← Writing
May 11, 2026·7 min read

Fixing TypeScript Errors in Platform-Specific React Native Code

Why .ios.tsx and .android.tsx resolve fine in Metro but break TypeScript, what Platform.select actually infers, and the moduleSuffixes trick that makes it all line up.

typescriptreact-nativetooling

React Native lets you write Button.ios.tsx and Button.android.tsx next to each other, import ./Button, and trust that Metro picks the right one at bundle time. Beautiful. Until you open the same project in VS Code and TypeScript paints the line red because it has no idea those files exist.

This is the toolchain pothole most React Native + TypeScript projects hit eventually. The fix is small but non-obvious, and the workarounds floating around the web are mostly stale. Here is the short, current version, plus the type-system gotchas that ship alongside it.

The setup that breaks first

You have three files:

components/
  Haptics.ts          // shared interface
  Haptics.ios.ts      // CoreHaptics-powered impl
  Haptics.android.ts  // Vibrator-powered impl

And you import the shared name:

import { tap } from "@/components/Haptics";

tap();

Metro resolves ./Haptics based on the current platform and pulls in Haptics.ios.ts or Haptics.android.ts. TypeScript does not know that convention. It looks for a literal Haptics.ts, fails to find one, and you get:

TS2307: Cannot find module '@/components/Haptics' or its corresponding type declarations.

If you create an empty Haptics.ts just to shut the error up, you now have a third file Metro can technically resolve on web, and you have lost type checking for the platform implementations because TS is checking the stub.

The real fix: moduleSuffixes

TypeScript 4.7 added a compiler option called moduleSuffixes that exists for exactly this scenario. It tells the resolver to try a list of suffixes before falling back to the bare name. For an iOS type-check pass:

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

With that in place, an import of ./Haptics resolves to Haptics.ios.ts if it exists, then Haptics.native.ts, then plain Haptics.ts. No more TS2307, no more stub files.

The catch: moduleSuffixes is a single ordered list per tsconfig. You cannot ask one compilation to type-check both iOS and Android at once. The pragmatic pattern is one base tsconfig and two thin overrides:

// tsconfig.json (shared rules, no moduleSuffixes)
{
  "compilerOptions": {
    "strict": true,
    "jsx": "react-native",
    "moduleResolution": "bundler",
    "paths": { "@/*": ["./*"] }
  }
}

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

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

Then wire them into the typecheck script:

"scripts": {
  "typecheck": "tsc -p tsconfig.ios.json --noEmit && tsc -p tsconfig.android.json --noEmit"
}

Two passes is the correct cost. Both implementations actually being type-safe is the thing you wanted. If one diverges from the shared interface, the failing pass tells you which.

Keeping the two implementations honest

The most useful pattern I have landed on is to write the contract once, in a sibling file that has no platform suffix:

// components/Haptics.types.ts
export interface HapticsModule {
  tap(): void;
  success(): Promise<void>;
}

Then each implementation imports and satisfies it:

// components/Haptics.ios.ts
import type { HapticsModule } from "./Haptics.types";

const impl: HapticsModule = {
  tap() { /* CoreHaptics call */ },
  async success() { /* ... */ },
};

export const { tap, success } = impl;
// components/Haptics.android.ts
import type { HapticsModule } from "./Haptics.types";

const impl: HapticsModule = {
  tap() { /* Vibrator.vibrate(10) */ },
  async success() { /* ... */ },
};

export const { tap, success } = impl;

Now if iOS adds a third method and Android forgets to, the Android typecheck pass fails on the missing property. This catches the most common platform-divergence bug at the compiler, not in QA.

Platform.select infers a union, not what you want

People reach for Platform.select when the split is small enough that two files feel excessive. The type signature is sneaky:

const padding = Platform.select({
  ios: 12,
  android: 16,
});
// padding: number | undefined

That | undefined is not a bug. Platform.select returns the value matching the current platform, and the current platform might be web or windows in a future build. The fix is the default key:

const padding = Platform.select({
  ios: 12,
  android: 16,
  default: 16,
});
// padding: number

The second pitfall is more subtle. If your branches return different shapes, the inferred type is a union, and you usually wanted an intersection or a single normalized shape:

const cfg = Platform.select({
  ios: { hapticsEngine: "core" },
  android: { vibrationMs: 10 },
  default: {},
});
// cfg: { hapticsEngine: string } | { vibrationMs: number } | {}

// Either narrow with a type parameter:
type Cfg = { hapticsEngine?: "core"; vibrationMs?: number };
const cfg2 = Platform.select<Cfg>({
  ios: { hapticsEngine: "core" },
  android: { vibrationMs: 10 },
  default: {},
});
// cfg2: Cfg

Telling Platform.select the type up front is almost always the right move once you have more than one field per branch.

Why if (Platform.OS === 'ios') does not narrow

You will write this and expect a smaller type inside the block:

if (Platform.OS === "ios") {
  // import the iOS-only module safely?
  const { Haptics } = require("./Haptics.ios");
}

Two problems. First, Platform.OS is typed as a wide string literal union and TypeScript does narrow the variable, but it does not change which files exist on disk. Metro is still the thing that swaps files based on extension, so this branch on web still tries to resolve ./Haptics.ios, finds nothing, and explodes at bundle time.

Second, require is not tree-shaken the way you would hope. Native code inside the iOS-only branch ships in the Android bundle. The right pattern is to keep the conditional implicit by using platform extensions, not by guarding imports at runtime. Resolve at the module boundary, not inside a function body.

If you genuinely need a runtime branch, narrow the platform literal yourself so the rest of the file benefits:

import { Platform } from "react-native";

type IOSPlatform = { OS: "ios"; Version: number };
type AndroidPlatform = { OS: "android"; Version: number };

const P = Platform as IOSPlatform | AndroidPlatform;

if (P.OS === "ios") {
  // P is IOSPlatform here, Version is number
}

The Metro vs TypeScript resolver mismatch, in one line

Metro resolves files by trying suffixes in a configured order (ios > native> bare for an iOS build). TypeScript only learned to do the same thing in 4.7, and still only one suffix list at a time. Everything in this post is the consequence of those two facts not lining up by default.

The older workarounds you will see in stale answers:

  • Ambient .d.ts shims. Declare declare module "./Haptics" and re-export the iOS types. Works, but you maintain a parallel set of declarations and lose all type checking on Android.
  • A neutral barrel file. Add a Haptics.ts that export * from one platform. Type checks pass, but Metro now has two valid resolutions and the winner depends on resolver order.
  • Custom resolver in tsconfig paths. Possible, but the resolver still cannot pick per-platform at compile time, only per-tsconfig.

None of these are wrong, but they were all written before moduleSuffixes existed. If your TypeScript is 4.7 or newer, use it.

What I would reach for now

One shared *.types.ts file per module that owns the contract. Two thin tsconfigs that flip moduleSuffixes. A typecheck script that runs both.Platform.select only for small, same-shape values, and always with an explicit type parameter. No require inside if (Platform.OS === ...) blocks.

That stack catches the platform-divergence class of bugs before the simulator boots, which is exactly where you want them caught.