TypeScript Playground Chrome Extension (2026)

Developing Chrome extensions with TypeScript requires a solid development environment that supports hot reloading, type checking, and smooth debugging. A well-configured TypeScript playground for Chrome extension development can significantly accelerate your workflow and catch errors before they reach production.

Why Use TypeScript for Chrome Extensions

TypeScript brings static typing to your extension development, making it easier to work with the Chrome Extensions API, content scripts, and background workers. The type definitions for Chrome APIs help you understand available methods and catch mistakes at compile time rather than runtime.

Modern Chrome extension development often involves complex build processes with bundlers like Vite, Rollup, or Webpack. Setting up a playground environment lets you experiment with different configurations without affecting your production build.

Setting Up Your Development Environment

The foundation of any TypeScript Chrome extension project starts with proper configuration. Create a new directory and initialize your project with the necessary dependencies:

mkdir chrome-extension-playground && cd chrome-extension-playground
npm init -y
npm install --save-dev typescript vite @types/chrome

Your tsconfig.json should enable strict mode and configure the output for browser-style modules:

{
 "compilerOptions": {
 "target": "ES2020",
 "module": "ESNext",
 "strict": true,
 "moduleResolution": "bundler",
 "lib": ["ES2020", "DOM"],
 "outDir": "./dist"
 },
 "include": ["src//*"]
}

The Vite configuration handles the bundling process, producing the final extension files:

import { defineConfig } from 'vite';
import manifest from './manifest.json';
export default defineConfig({
 build: {
 outDir: 'dist',
 emptyOutDir: true,
 rollupOptions: {
 input: {
 background: 'src/background.ts',
 popup: 'src/popup/index.html',
 content: 'src/content.ts'
 }
 }
 },
 plugins: [{
 name: 'manifest',
 generateBundle(_, bundle) {
 this.emitFile({ type: 'asset', fileName: 'manifest.json', source: JSON.stringify(manifest) });
 }
 }]
});

Working with Manifest V3

Chrome now requires Manifest V3 for all extensions, which changes how background scripts operate. Instead of persistent background pages, you now use service workers. Here’s how to structure your background service worker:

// src/background.ts
chrome.runtime.onInstalled.addListener((details) => {
 if (details.reason === 'install') {
 console.log('Extension installed');
 }
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
 if (message.action === 'getData') {
 // Handle message from content script or popup
 sendResponse({ data: 'Response from background' });
 }
 return true; // Keep message channel open for async response
});

Content Script Types

Content scripts run in the context of web pages and need careful type handling. Create a typesafe wrapper:

// src/utils/content-script.ts
interface PageData {
 title: string;
 url: string;
 elements: number;
}
export function getPageInfo(): PageData {
 return {
 title: document.title,
 url: window.location.href,
 elements: document.querySelectorAll('*').length
 };
}
export function injectScript(fn: () => void): void {
 const script = document.createElement('script');
 script.textContent = `(${fn.toString()})()`;
 document.documentElement.appendChild(script);
 script.remove();
}

The popup UI uses HTML, CSS, and TypeScript. Here’s a simple popup structure:

// src/popup/main.ts
document.addEventListener('DOMContentLoaded', () => {
 const button = document.getElementById('action-btn');
 
 button?.addEventListener('click', async () => {
 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
 
 if (tab.id) {
 await chrome.tabs.sendMessage(tab.id, { action: 'process' });
 }
 });
});

Loading Your Extension

After building your extension, load it into Chrome:

  1. Navigate to chrome://extensions/
  2. Enable “Developer mode” in the top right
  3. Click “Load unpacked”
  4. Select your dist directory

For development, use Chrome’s auto-reload feature or install an extension like “Extension Reloader” to refresh without manually reloading.

Debugging Techniques

The Chrome DevTools work with your extension components. Access debugging through:

  • Background service worker: Find it in chrome://extensions/ under “Service Worker”
  • Popup: Right-click the extension icon and choose “Inspect popup”
  • Content scripts: Use the regular DevTools console for the page you’re viewing

Add logging throughout your code:

console.log('[Background] Starting initialization');
console.info('[Content] Page elements found:', document.body.children.length);
console.warn('[Popup] Action button not found');

Testing Your Extension

Write tests for your extension logic using Vitest or Jest:

// src/__tests__/utils.test.ts
import { describe, it, expect } from 'vitest';
import { getPageInfo } from '../utils/content-script';
describe('Content Script Utilities', () => {
 it('should return page information', () => {
 const info = getPageInfo();
 expect(info).toHaveProperty('title');
 expect(info).toHaveProperty('url');
 expect(info).toHaveProperty('elements');
 });
});

Run tests with the jsdom environment or use Puppeteer for integration testing with a real browser context.

Common Pitfalls

Avoid these frequent issues when developing Chrome extensions with TypeScript:

  • Missing permissions: Always declare required permissions in your manifest
  • Content script isolation: Remember content scripts run in an isolated world
  • Service worker timeouts: Service workers can terminate after 30 seconds of inactivity
  • Cross-origin requests: Use the Chrome API for network requests from background scripts

Hot Reload Without Manual Refresh

One of the most frustrating parts of Chrome extension development is the constant cycle of building, reloading the extension in chrome://extensions/, and refreshing the test page. You can eliminate most of this friction with a watch script combined with a small reload helper.

Add a watch script to your package.json:

{
 "scripts": {
 "dev": "vite build --watch",
 "build": "vite build"
 }
}

Then install web-ext from Mozilla, which works with Chromium-based browsers for auto-reloading during development:

npm install --save-dev web-ext

Run both in parallel:

npm run dev &
npx web-ext run --target=chromium --source-dir=dist

For projects where web-ext is overkill, you can write a lightweight background script that polls for file changes using the chrome.runtime.reload() API. The key insight is that the service worker can call chrome.runtime.reload() programmatically, so any reload trigger you can inject, whether through a WebSocket connection to a dev server or a simple polling interval, works cleanly.

Structuring Message Passing with Types

The Chrome message passing system is a frequent source of runtime bugs because messages are untyped by default. A typed message bus prevents an entire class of errors where the sender and receiver disagree on message shape.

Define a discriminated union for all your messages in a shared types file:

// src/types/messages.ts
export type ExtensionMessage =
 | { action: 'getData'; tabId: number }
 | { action: 'setData'; payload: string }
 | { action: 'ping' };
export type ExtensionResponse =
 | { success: true; data: string }
 | { success: false; error: string };

Then create typed wrappers around the raw Chrome APIs:

// src/utils/messaging.ts
import type { ExtensionMessage, ExtensionResponse } from '../types/messages';
export function sendToBackground(
 message: ExtensionMessage
): Promise<ExtensionResponse> {
 return new Promise((resolve, reject) => {
 chrome.runtime.sendMessage(message, (response: ExtensionResponse) => {
 if (chrome.runtime.lastError) {
 reject(new Error(chrome.runtime.lastError.message));
 } else {
 resolve(response);
 }
 });
 });
}
export function sendToTab(
 tabId: number,
 message: ExtensionMessage
): Promise<ExtensionResponse> {
 return new Promise((resolve, reject) => {
 chrome.tabs.sendMessage(tabId, message, (response: ExtensionResponse) => {
 if (chrome.runtime.lastError) {
 reject(new Error(chrome.runtime.lastError.message));
 } else {
 resolve(response);
 }
 });
 });
}

On the receiving end, your handler narrows the type automatically:

chrome.runtime.onMessage.addListener(
 (message: ExtensionMessage, sender, sendResponse) => {
 if (message.action === 'getData') {
 // TypeScript knows message.tabId exists here
 fetchData(message.tabId).then((data) => {
 sendResponse({ success: true, data });
 });
 return true;
 }
 if (message.action === 'ping') {
 sendResponse({ success: true, data: 'pong' });
 }
 }
);

This pattern catches mismatches at compile time rather than during a frustrating debugging session at 11pm.

Storage Abstraction with TypeScript

Chrome’s storage.local and storage.sync APIs are weakly typed. Wrapping them in a typed storage layer makes your code far easier to maintain and refactor.

// src/utils/storage.ts
interface StorageSchema {
 userPreferences: {
 theme: 'light' | 'dark';
 fontSize: number;
 autoSave: boolean;
 };
 sessionData: {
 lastVisited: string;
 itemCount: number;
 };
}
type StorageKey = keyof StorageSchema;
export async function getStorage<K extends StorageKey>(
 key: K
): Promise<StorageSchema[K] | null> {
 return new Promise((resolve) => {
 chrome.storage.local.get(key, (result) => {
 resolve((result[key] as StorageSchema[K]) ?? null);
 });
 });
}
export async function setStorage<K extends StorageKey>(
 key: K,
 value: StorageSchema[K]
): Promise<void> {
 return new Promise((resolve) => {
 chrome.storage.local.set({ [key]: value }, resolve);
 });
}

Usage becomes completely type-safe:

// TypeScript enforces the correct shape
await setStorage('userPreferences', {
 theme: 'dark',
 fontSize: 14,
 autoSave: true
});
const prefs = await getStorage('userPreferences');
if (prefs) {
 console.log(prefs.theme); // typed as 'light' | 'dark'
}

This pattern also makes it straightforward to add storage migrations later when your schema changes between extension versions.

Handling Permissions at Runtime

Manifest V3 encourages requesting permissions at runtime rather than bundling everything in the manifest upfront. TypeScript makes it easy to build a safe permissions helper:

// src/utils/permissions.ts
type ChromePermission = chrome.permissions.Permissions;
export async function requestPermission(
 permission: string,
 origins?: string[]
): Promise<boolean> {
 const request: ChromePermission = { permissions: [permission] };
 if (origins) {
 request.origins = origins;
 }
 return new Promise((resolve) => {
 chrome.permissions.request(request, (granted) => {
 resolve(granted);
 });
 });
}
export async function hasPermission(permission: string): Promise<boolean> {
 return new Promise((resolve) => {
 chrome.permissions.contains(
 { permissions: [permission] },
 (result) => resolve(result)
 );
 });
}

Call this before any feature that requires elevated permissions rather than letting the browser surface a confusing error to the user:

async function readClipboard(): Promise<string | null> {
 const granted = await hasPermission('clipboardRead');
 if (!granted) {
 const approved = await requestPermission('clipboardRead');
 if (!approved) {
 console.warn('[Content] Clipboard permission denied');
 return null;
 }
 }
 return navigator.clipboard.readText();
}

Production Build

When ready to publish, create a production build:

npm run build

This generates optimized files in your dist directory. Test the production build locally before submitting to the Chrome Web Store. Before submitting, run through a manual checklist: verify all declared permissions are actually used (the Chrome Web Store reviewers check this), confirm your content security policy in the manifest does not include unsafe-eval, and test the extension in an incognito window where most extensions are disabled by default.

Consider adding a pre-publish script that zips only the dist directory:

cd dist && zip -r ../extension.zip . && cd ..

This creates a clean package ready for the Chrome Web Store upload form.

A well-configured TypeScript playground for Chrome extension development gives you confidence in your code quality and speeds up iteration. The typed message passing, storage abstraction, and permission helpers described here prevent the most common runtime errors while making your codebase easier for collaborators to navigate. The setup described here provides a solid foundation for building solid extensions with modern JavaScript tooling.


Try it: Paste your error into our Error Diagnostic for an instant fix.

I'm a solo developer in Vietnam. 50K Chrome extension users. $500K+ on Upwork. 5 Claude Max subscriptions running agent fleets in parallel. These are my actual CLAUDE.md templates, orchestration configs, and prompts. Not a course. Not theory. The files I copy into every project before I write a line of code. **[See what's inside →](https://zovo.one/lifetime?utm_source=ccg&utm_medium=cta-default&utm_campaign=chrome-extension-typescript-playground)** $99 once. Free forever. 47/500 founding spots left.

Related Reading

Built by theluckystrike. More at zovo.one

Find the right skill → Browse 155+ skills in our Skill Finder.

Configure permissions → Build your settings with our Permission Configurator.