Building Chrome Extensions with TypeScript — Developer Guide

22 min read

Building Chrome Extensions with TypeScript

TypeScript has become the standard for building robust Chrome extensions. It provides compile-time type safety for Chrome APIs, enables intelligent autocomplete, and catches errors before runtime. This comprehensive guide covers everything you need to build type-safe Chrome extensions.

Table of Contents


Project Setup

Initializing Your Project

Create a new extension project with TypeScript:

mkdir my-typescript-extension && cd my-typescript-extension
npm init -y
npm install --save-dev typescript @types/chrome esbuild
mkdir -p src/background src/content src/popup src/shared

Directory Structure

A well-organized TypeScript extension project:

my-extension/
├── src/
│   ├── background/
│   │   └── service-worker.ts    # Background service worker
│   ├── content/
│   │   └── content-script.ts    # Content script
│   ├── popup/
│   │   ├── popup.ts             # Popup logic
│   │   └── popup.html           # Popup UI
│   ├── options/
│   │   ├── options.ts           # Options page
│   │   └── options.html         # Options UI
│   └── shared/
│       ├── types.ts             # Shared type definitions
│       └── messages.ts          # Message type definitions
├── dist/                        # Compiled output
├── manifest.json
├── tsconfig.json
└── package.json

Configuring tsconfig.json

Base Configuration

Chrome extensions run in multiple contexts—some with DOM (popup, options, content scripts) and some without (service worker). Your tsconfig.json must account for these differences:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "outDir": "dist",
    "rootDir": "src",
    "sourceMap": true,
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Context-Specific Configurations

For complex extensions, use separate tsconfig files for different contexts:

// tsconfig.background.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "lib": ["ES2022"],
    "types": ["chrome"]
  },
  "include": ["src/background/**/*", "src/shared/**/*"]
}
// tsconfig.ui.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "types": ["chrome"]
  },
  "include": ["src/popup/**/*", "src/options/**/*", "src/shared/**/*"]
}

Typing Chrome APIs

Installing Type Definitions

Install the official Chrome type definitions:

npm install --save-dev @types/chrome

This provides full type support for all Chrome extension APIs.

Using Typed Chrome APIs

With @types/chrome installed, you get full autocomplete and type checking:

// Fully typed - TypeScript knows the exact shape
const queryOptions = { active: true, currentWindow: true };

chrome.tabs.query(queryOptions, (tabs) => {
  // tabs is fully typed as chrome.tabs.Tab[]
  const activeTab = tabs[0];
  
  if (activeTab.id && activeTab.url) {
    console.log(activeTab.id, activeTab.url);
  }
});

// Async/await pattern with proper typing
async function getActiveTab(): Promise<chrome.tabs.Tab | null> {
  const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
  return tabs[0] ?? null;
}

// Typed storage operations
async function saveSettings(settings: Settings): Promise<void> {
  await chrome.storage.local.set({ settings });
}

async function loadSettings(): Promise<Settings | null> {
  const result = await chrome.storage.local.get('settings');
  return result.settings ?? null;
}

Extending Chrome Types

When Chrome APIs don’t cover your use case, extend the types:

// src/shared/types/chrome-extensions.d.ts

declare namespace chrome.storage {
  interface StorageArea {
    get<T>(keys: string | string[] | null): Promise<Record<string, T>>;
    set<T>(items: Record<string, T>): Promise<void>;
  }
}

// Extend Tab type with custom properties
interface CustomTab extends chrome.tabs.Tab {
  customData?: {
    lastAccessed: number;
    visitCount: number;
  };
}

Typed Messaging

Defining Message Types

Create a centralized message type definition:

// src/shared/messages.ts

export type MessageMap = {
  // Request-response messages
  getSettings: {
    request: void;
    response: Settings;
  };
  
  saveBookmark: {
    request: { url: string; title: string; tags: string[] };
    response: { id: string; success: boolean };
  };
  
  // Fire-and-forget messages
  logAnalytics: {
    request: { event: string; data: Record<string, unknown> };
    response: void;
  };
  
  // Tab-specific messages
  highlightElements: {
    request: { selector: string; color: string };
    response: { count: number };
  };
};

export type MessageType = keyof MessageMap;
export type Request<T extends MessageType> = MessageMap[T]['request'];
export type Response<T extends MessageType> = MessageMap[T]['response'];

Typed Message Handler

Create type-safe message handlers:

// src/background/message-handler.ts

import { MessageMap } from '../shared/messages';

type MessageHandler<T extends keyof MessageMap> = (
  request: MessageMap[T]['request']
) => Promise<MessageMap[T]['response']>;

const handlers: {
  [K in keyof MessageMap]?: MessageHandler<K>;
} = {};

export function registerHandler<T extends keyof MessageMap>(
  type: T,
  handler: MessageHandler<T>
): void {
  handlers[type] = handler;
}

export async function handleMessage(
  message: { type: string; payload?: unknown },
  sender: chrome.runtime.MessageSender
): Promise<unknown> {
  const handler = handlers[message.type as keyof MessageMap];
  
  if (!handler) {
    throw new Error(`No handler registered for message type: ${message.type}`);
  }
  
  return handler(message.payload as any);
}

// Register handlers
registerHandler('getSettings', async () => {
  const result = await chrome.storage.local.get('settings');
  return result.settings ?? DEFAULT_SETTINGS;
});

registerHandler('saveBookmark', async (request) => {
  const id = generateId();
  await chrome.storage.local.set({ [id]: request });
  return { id, success: true };
});

Sending Typed Messages

// src/content/content-script.ts

import { MessageMap } from '../shared/messages';

async function sendMessage<T extends keyof MessageMap>(
  type: T,
  payload: MessageMap[T]['request']
): Promise<MessageMap[T]['response']> {
  return new Promise((resolve, reject) => {
    chrome.runtime.sendMessage({ type, payload }, (response) => {
      if (chrome.runtime.lastError) {
        reject(new Error(chrome.runtime.lastError.message));
      } else {
        resolve(response);
      }
    });
  });
}

// Usage - fully typed
const settings = await sendMessage('getSettings', void 0);
const result = await sendMessage('saveBookmark', {
  url: 'https://example.com',
  title: 'Example',
  tags: ['bookmark'],
});

Typed Storage Wrappers

Basic Typed Storage

Create a type-safe storage wrapper:

// src/shared/storage.ts

export interface Settings {
  theme: 'light' | 'dark' | 'system';
  notifications: boolean;
  maxResults: number;
  syncEnabled: boolean;
}

const DEFAULT_SETTINGS: Settings = {
  theme: 'system',
  notifications: true,
  maxResults: 50,
  syncEnabled: false,
};

class TypedStorage {
  private area: chrome.storage.StorageArea;
  
  constructor(area: 'local' | 'sync' | 'managed') {
    this.area = chrome.storage[area];
  }
  
  async get<K extends keyof Settings>(
    key: K
  ): Promise<Settings[K]> {
    const result = await this.area.get(key);
    return (result[key] ?? DEFAULT_SETTINGS[key]) as Settings[K];
  }
  
  async set<K extends keyof Settings>(
    key: K,
    value: Settings[K]
  ): Promise<void> {
    await this.area.set({ [key]: value });
  }
  
  async getAll(): Promise<Settings> {
    const result = await this.area.get(null);
    return { ...DEFAULT_SETTINGS, ...result } as Settings;
  }
  
  async setAll(settings: Partial<Settings>): Promise<void> {
    await this.area.set({ ...DEFAULT_SETTINGS, ...settings });
  }
}

export const localStorage = new TypedStorage('local');
export const syncStorage = new TypedStorage('sync');

Storage with Validation

Add runtime validation with Zod:

// src/shared/storage-validated.ts

import { z } from 'zod';

const SettingsSchema = z.object({
  theme: z.enum(['light', 'dark', 'system']).default('system'),
  notifications: z.boolean().default(true),
  maxResults: z.number().min(1).max(100).default(50),
  syncEnabled: z.boolean().default(false),
});

type Settings = z.infer<typeof SettingsSchema>;

class ValidatedStorage {
  private storage: chrome.storage.StorageArea;
  private schema: z.ZodSchema;
  
  constructor(area: chrome.storage.StorageArea, schema: z.ZodSchema) {
    this.storage = area;
    this.schema = schema;
  }
  
  async getAll(): Promise<unknown> {
    const data = await this.storage.get(null);
    return this.schema.parse(data);
  }
  
  async set(value: unknown): Promise<void> {
    const validated = this.schema.parse(value);
    await this.storage.set(validated);
  }
}

Build Tooling

esbuild Configuration

esbuild is the fastest option for building extensions:

// build.ts

import * as esbuild from 'esbuild';
import { copyFileSync, existsSync, mkdirSync } from 'fs';
import { resolve } from 'path';

const isWatch = process.argv.includes('--watch');
const isProd = process.argv.includes('--production');

const commonOptions: esbuild.BuildOptions = {
  sourcemap: !isProd,
  minify: isProd,
  target: ['chrome110'],
  format: 'iife',
  bundle: true,
  logLevel: 'info',
};

async function build() {
  // Background service worker
  await esbuild.build({
    ...commonOptions,
    entryPoints: ['src/background/service-worker.ts'],
    outfile: 'dist/background/service-worker.js',
    target: ['chrome110'],
  });
  
  // Content scripts
  await esbuild.build({
    ...commonOptions,
    entryPoints: ['src/content/content-script.ts'],
    outfile: 'dist/content/content-script.js',
  });
  
  // Popup
  await esbuild.build({
    ...commonOptions,
    entryPoints: ['src/popup/popup.ts'],
    outfile: 'dist/popup/popup.js',
  });
  
  // Copy static files
  copyFileSync('src/popup/popup.html', 'dist/popup/popup.html');
  copyFileSync('manifest.json', 'dist/manifest.json');
}

build();

Vite Configuration

Vite provides an excellent developer experience:

// vite.config.ts

import { defineConfig } from 'vite';
import { resolve } from 'path';

export default defineConfig({
  build: {
    outDir: 'dist',
    emptyOutDir: true,
    rollupOptions: {
      input: {
        background: resolve(__dirname, 'src/background/service-worker.ts'),
        popup: resolve(__dirname, 'src/popup/popup.html'),
        options: resolve(__dirname, 'src/options/options.html'),
        content: resolve(__dirname, 'src/content/content-script.ts'),
      },
      output: {
        entryFileNames: (chunkInfo) => {
          const map: Record<string, string> = {
            background: 'background/service-worker.js',
            content: 'content/content-script.js',
          };
          return map[chunkInfo.name] ?? `${chunkInfo.name}/index.js`;
        },
      },
    },
  },
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
      '@shared': resolve(__dirname, 'src/shared'),
    },
  },
});

webpack Configuration

For complex builds with code splitting:

// webpack.config.js

const path = require('path');

module.exports = {
  mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
  entry: {
    'background/service-worker': './src/background/service-worker.ts',
    'content/content-script': './src/content/content-script.ts',
    'popup/popup': './src/popup/popup.ts',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].js',
  },
  resolve: {
    extensions: ['.ts', '.js'],
    alias: {
      '@shared': path.resolve(__dirname, 'src/shared'),
    },
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
    ],
  },
};

Common Type Patterns

Nullable Tab Handling

// Safe tab access with proper null handling
async function safeTabAccess(tabId: number): Promise<string | null> {
  try {
    const tab = await chrome.tabs.get(tabId);
    return tab.url ?? null;
  } catch (error) {
    console.error('Tab not found:', error);
    return null;
  }
}

// Optional chaining for nested properties
function getTabTitle(tab: chrome.tabs.Tab): string {
  return tab.title ?? 'Untitled';
}

Manifest Type Safety

// Typed manifest configuration
import type { Manifest } from './types/manifest';

const manifest: Manifest.V3 = {
  manifest_version: 3,
  name: 'My Extension',
  version: '1.0.0',
  background: {
    service_worker: 'background/service-worker.js',
    type: 'module',
  },
  permissions: ['storage', 'tabs'],
  host_permissions: ['<all_urls>'],
};

Promise-Based Chrome API Wrapper

// Convert callback-based APIs to promises
function getStoredValue<T>(key: string): Promise<T | null> {
  return new Promise((resolve) => {
    chrome.storage.local.get(key, (result) => {
      resolve(result[key] ?? null);
    });
  });
}

function setStoredValue<T>(key: string, value: T): Promise<void> {
  return new Promise((resolve) => {
    chrome.storage.local.set({ [key]: value }, resolve);
  });
}

Event Type Safety

// Typed event listeners
chrome.tabs.onUpdated.addListener(
  (tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => {
    if (changeInfo.status === 'complete' && tab.url) {
      console.log('Tab loaded:', tab.url);
    }
  }
);

// Custom event types
interface ExtensionEvent<T> {
  addListener(callback: (data: T) => void): void;
  removeListener(callback: (data: T) => void): void;
}

Debugging TypeScript Extensions

Source Maps in Production

Enable source maps for debugging deployed extensions:

// tsconfig.json
{
  "compilerOptions": {
    "sourceMap": true,
    "declaration": true,
    "declarationMap": true
  }
}

In your build configuration:

// esbuild build options
{
  sourcemap: true,
  minify: false, // Disable minification for easier debugging
}

Chrome DevTools Tips

  1. Service Worker Debugging: Open chrome://extensions and click “service worker” link to access background context
  2. Content Script Debugging: Use the page’s DevTools (not extension DevTools) for content scripts
  3. Console Filtering: Filter by context using dropdown in Console tab

Type-Safe Console Logging

// Debug utilities with type information
function debug<T>(label: string, value: T): void {
  console.log(`[${label}]`, value);
  console.log(`Type: ${typeof value}`);
}

function debugJson<T>(label: string, value: T): void {
  console.log(`[${label}]`, JSON.stringify(value, null, 2));
}

Common TypeScript Errors

Error: Property 'X' does not exist on type 'Y'

Error: Module '"chrome"' has no exported member 'storage'

Error: Expression of type 'X' can't be used to index type 'Y'



Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.

No previous article
No next article