Types in an Expo Project

New Expo projects are TypeScript already: tsconfig extends expo/tsconfig.base, and tsc checks what Metro merely strips.

Syntaxnpx tsc # type-check only: expo/tsconfig.base sets "noEmit": true

A new Expo project is a TypeScript project. npx create-expo-app writes .tsx files, a tsconfig.json, and typescript plus @types/react in devDependencies. Nothing asks you to opt in.

Two different jobs

Metro, the bundler, hands your files to Babel, which deletes the type annotations and ships the JavaScript. It never checks them. The checking is a separate program, tsc, run by your editor as you type and by npx tsc on demand. A type error therefore does not stop the app reloading. It also does not go away.

The config

The template's tsconfig.json extends expo/tsconfig.base, which sets "jsx": "react-jsx", "moduleResolution": "bundler" and "noEmit": true. That last one is why npx tsc reports problems and writes no output files. The template itself adds "strict": true and the @/* path alias. Expo CLI reads paths too, so an aliased import resolves at runtime as well as in the editor; restart the dev server after changing it.

Generated files

Expo CLI writes expo-env.d.ts and a .expo types directory. Both are listed in include and both are git-ignored. Do not edit or delete them. They are regenerated when the dev server starts.

To add types to an existing JavaScript app, run npx expo install typescript @types/react --dev, rename a file to .tsx, and generate a config with npx expo customize tsconfig.json.

Example

Example · tsx
// src/components/Greeting.tsx
import { StyleSheet, Text, View } from 'react-native';

// A props type. The compiler checks every <Greeting /> against it.
// Babel deletes this block before the app runs, so it costs nothing
// at runtime - and is never enforced at runtime either.
type GreetingProps = {
  name: string;
  subtitle?: string; // optional: callers may leave it out
};

export default function Greeting({
  name,
  subtitle = 'Welcome back',
}: GreetingProps) {
  return (
    <View style={styles.container}>
      {/* A bare string here would throw. Text has to wrap it. */}
      <Text style={styles.name}>{name}</Text>
      <Text style={styles.subtitle}>{subtitle}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    padding: 16,
    // flexDirection defaults to 'column' here, not 'row' as in CSS
    gap: 4,
  },
  name: { fontSize: 20, fontWeight: '600' },
  subtitle: { fontSize: 14, color: '#555555' },
});

When to use it

  • Start a project where every component's props are checked from the first commit.
  • Add a type-check step to CI so a broken prop cannot reach an EAS build.
  • Declare a Product or User shape once and import it into every screen that uses it.
  • Convert an existing JavaScript Expo app to TypeScript file by file.

More examples

The tsconfig.json a new project gets

This is the shape the Expo SDK 57 default template ships. Everything general lives in expo/tsconfig.base; the project file only adds what is a project decision: strict checking and the @/ alias. The two entries in include are the generated type files, which is why deleting them breaks the build until the dev server regenerates them. Reach for this file when you add an alias or want stricter flags - and restart npx expo start afterwards, because Metro reads the alias list at startup.

Example · json
{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": [
    "**/*.ts",
    "**/*.tsx",
    ".expo/types/**/*.ts",
    "expo-env.d.ts"
  ]
}

One shared type, imported through the alias

A type declared once in its own file and imported where it is needed. `import type` makes it explicit that nothing is pulled into the bundle - the import disappears entirely. This is the pattern for any shape that more than one screen touches, and it is what stops two components disagreeing about whether a price is cents or pounds.

Example · tsx
// src/types/product.ts
export type Product = {
  id: string;
  title: string;
  priceCents: number;
};

// src/components/PriceTag.tsx
import { Text } from 'react-native';
// '@/' is the alias declared in tsconfig.json, not a package name
import type { Product } from '@/types/product';

type PriceTagProps = { product: Product };

export default function PriceTag({ product }: PriceTagProps) {
  return <Text>{(product.priceCents / 100).toFixed(2)}</Text>;
}

Adding types to an existing JavaScript project

The migration order that avoids a wall of errors: install, rename one file, check, repeat. Because expo/tsconfig.base sets allowJs, a half-converted project still bundles and runs, so you can move file by file. Run the last command in CI as well - a type error never breaks the dev server, so nothing else will catch it before a release build.

Example · sh
# install the compiler and the React types as dev dependencies
npx expo install typescript @types/react --dev

# rename files: .tsx when the file contains JSX, .ts when it does not
mv App.js App.tsx

# write a tsconfig.json that extends expo/tsconfig.base
npx expo customize tsconfig.json

# check the whole project; prints errors and emits nothing
npx tsc

Discussion

  • Be the first to comment on this lesson.