Skip to main content
CRXJS fully supports TypeScript out of the box. This guide covers TypeScript configuration, Chrome API types, and best practices for type-safe extension development.

Basic TypeScript Setup

Install TypeScript and Chrome types:
npm install -D typescript @types/chrome @types/node

TypeScript Configuration

Create a tsconfig.json for your project:
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "moduleDetection": "force",
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    },
    "types": ["vite/client", "chrome"],
    "allowImportingTsExtensions": true,
    "strict": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noEmit": true,
    "isolatedModules": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

Project References

For larger projects, use TypeScript project references:
tsconfig.json
{
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ],
  "files": []
}
tsconfig.app.json
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "target": "ES2020",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    },
    "types": ["vite/client", "chrome"],
    "strict": true,
    "noEmit": true,
    "isolatedModules": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}
tsconfig.node.json
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
    "target": "ES2022",
    "lib": ["ES2023"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "types": ["node"],
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["vite.config.ts", "manifest.config.ts"]
}

Chrome API Types

The @types/chrome package provides complete types for Chrome extension APIs.

Using Chrome APIs

background.ts
// Chrome APIs are fully typed
chrome.runtime.onInstalled.addListener((details) => {
  console.log('Extension installed:', details.reason)
})

// TypeScript knows the shape of chrome.tabs.Tab
chrome.tabs.query({ active: true }, (tabs: chrome.tabs.Tab[]) => {
  const activeTab = tabs[0]
  console.log('Active tab:', activeTab.title)
})

Promises with Chrome APIs

Chrome MV3 APIs support both callbacks and promises:
// Using promises (recommended)
const tabs = await chrome.tabs.query({ active: true })
console.log('Active tabs:', tabs)

// Using callbacks
chrome.tabs.query({ active: true }, (tabs) => {
  console.log('Active tabs:', tabs)
})

Manifest Types

Use defineManifest for type-safe manifest configuration:
manifest.config.ts
import { defineManifest } from '@crxjs/vite-plugin'
import pkg from './package.json'

export default defineManifest({
  manifest_version: 3,
  name: pkg.name,
  version: pkg.version,
  icons: {
    48: 'public/logo.png',
  },
  action: {
    default_icon: {
      48: 'public/logo.png',
    },
    default_popup: 'src/popup/index.html',
  },
  background: {
    service_worker: 'src/background.ts',
    type: 'module',
  },
  permissions: [
    'storage',
    'tabs',
  ],
  content_scripts: [{
    js: ['src/content/main.ts'],
    matches: ['https://*/*'],
  }],
})
defineManifest provides full TypeScript IntelliSense and type checking for your manifest.

Path Aliases

Configure path aliases for cleaner imports:
tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"],
      "@utils/*": ["src/utils/*"]
    }
  }
}
vite.config.ts
import path from 'node:path'
import { defineConfig } from 'vite'

export default defineConfig({
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
      '@components': path.resolve(__dirname, 'src/components'),
      '@utils': path.resolve(__dirname, 'src/utils'),
    },
  },
})
Now you can import with clean paths:
import { Button } from '@components/Button'
import { formatDate } from '@utils/date'

Type-safe Message Passing

Create type-safe communication between extension contexts:
types/messages.ts
export type Message =
  | { type: 'GET_TAB_INFO'; tabId: number }
  | { type: 'UPDATE_BADGE'; text: string }
  | { type: 'SAVE_DATA'; data: Record<string, unknown> }

export type MessageResponse<T extends Message> = 
  T extends { type: 'GET_TAB_INFO' } ? chrome.tabs.Tab :
  T extends { type: 'UPDATE_BADGE' } ? { success: boolean } :
  T extends { type: 'SAVE_DATA' } ? { success: boolean } :
  never
utils/messaging.ts
import type { Message, MessageResponse } from '@/types/messages'

export async function sendMessage<T extends Message>(
  message: T
): Promise<MessageResponse<T>> {
  return chrome.runtime.sendMessage(message)
}
Use it in your extension:
import { sendMessage } from '@/utils/messaging'

// TypeScript knows the response type!
const tab = await sendMessage({ 
  type: 'GET_TAB_INFO', 
  tabId: 123 
})

const result = await sendMessage({ 
  type: 'UPDATE_BADGE', 
  text: 'New' 
})

Type-safe Storage

Create typed wrappers for Chrome storage:
utils/storage.ts
interface StorageSchema {
  theme: 'light' | 'dark'
  count: number
  user: {
    name: string
    email: string
  }
}

export async function getStorage<K extends keyof StorageSchema>(
  key: K
): Promise<StorageSchema[K] | undefined> {
  const result = await chrome.storage.sync.get(key)
  return result[key]
}

export async function setStorage<K extends keyof StorageSchema>(
  key: K,
  value: StorageSchema[K]
): Promise<void> {
  await chrome.storage.sync.set({ [key]: value })
}
Use it with full type safety:
// TypeScript knows theme is 'light' | 'dark'
const theme = await getStorage('theme')

// TypeScript enforces the correct type
await setStorage('theme', 'dark') // ✓
await setStorage('theme', 'blue') // ✗ Type error!

// Complex objects are also typed
const user = await getStorage('user')
console.log(user?.name, user?.email)

Import Assertions

Import JSON files with type safety:
import pkg from './package.json' with { type: 'json' }

console.log(pkg.name, pkg.version)

Framework-Specific TypeScript

tsconfig.app.json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "types": ["vite/client", "chrome"]
  }
}

Build Script

Add TypeScript type checking to your build:
package.json
{
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "type-check": "tsc -b --noEmit"
  }
}

Best Practices

Use Strict Mode

Enable strict TypeScript checks:
{
  "compilerOptions": {
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  }
}

Avoid any

Use unknown instead of any when type is uncertain:
// Bad
function handleMessage(message: any) {
  console.log(message.type)
}

// Good
function handleMessage(message: unknown) {
  if (typeof message === 'object' && message !== null) {
    console.log((message as Message).type)
  }
}

Use Type Guards

Create type guards for runtime type checking:
function isTab(obj: unknown): obj is chrome.tabs.Tab {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'url' in obj
  )
}

chrome.runtime.onMessage.addListener((message) => {
  if (isTab(message)) {
    console.log('Received tab:', message.url)
  }
})

Next Steps

Build docs developers (and LLMs) love