Designing a TypeScript Playground

Design considerations and implementation strategies for building a TypeScript playground that provides real type checking, autocompletion, and compilation entirely within the browser.

technical7 min readBy Klivvr Engineering
Share:

TypeScript's type system is one of its greatest strengths, but it also makes building a TypeScript playground significantly more complex than one for plain JavaScript. A JavaScript playground only needs to execute code. A TypeScript playground must also compile it, check it, and provide intelligent editing features powered by the type system. The TypeScript compiler is a substantial piece of software, roughly 10MB of JavaScript in its uncompressed form, and running it in the browser at interactive speeds requires thoughtful architecture. This article describes how Kodepad delivers a full TypeScript editing and compilation experience in the browser, covering compiler integration, type acquisition, error handling, and the design decisions that shape the user experience.

Running the TypeScript Compiler in the Browser

The TypeScript compiler (tsc) is written in TypeScript and compiles to JavaScript, which means it can run in any JavaScript environment, including the browser. The challenge is not whether it can run but whether it can run fast enough. Compiling even a small file involves parsing, binding, type checking, and emitting, each of which touches complex data structures.

Monaco Editor already includes the TypeScript compiler as part of its language service worker. This gives us diagnostics, autocompletion, hover information, and go-to-definition out of the box. But for compilation, producing the JavaScript output that runs in the preview, we need direct access to the compiler API:

// src/compiler/typescript-compiler.ts
import ts from "typescript";
 
interface CompilationResult {
  code: string;
  sourceMap: string | null;
  diagnostics: DiagnosticInfo[];
  duration: number;
}
 
interface DiagnosticInfo {
  messageText: string;
  line: number;
  column: number;
  category: "error" | "warning" | "suggestion";
}
 
class BrowserTypeScriptCompiler {
  private compilerOptions: ts.CompilerOptions;
 
  constructor() {
    this.compilerOptions = {
      target: ts.ScriptTarget.ES2020,
      module: ts.ModuleKind.None,
      strict: true,
      esModuleInterop: true,
      sourceMap: true,
      inlineSourceMap: false,
      jsx: ts.JsxEmit.React,
      lib: ["es2020", "dom"],
      noEmitOnError: false, // Always emit, even with errors
    };
  }
 
  compile(source: string, filename: string = "main.ts"): CompilationResult {
    const start = performance.now();
    let outputCode = "";
    let outputSourceMap: string | null = null;
    const diagnostics: DiagnosticInfo[] = [];
 
    // Create a virtual file system for the compiler
    const sourceFile = ts.createSourceFile(
      filename,
      source,
      ts.ScriptTarget.ES2020,
      true
    );
 
    const host: ts.CompilerHost = {
      getSourceFile: (name) =>
        name === filename ? sourceFile : undefined,
      getDefaultLibFileName: () => "lib.d.ts",
      writeFile: (name, text) => {
        if (name.endsWith(".js")) outputCode = text;
        if (name.endsWith(".js.map")) outputSourceMap = text;
      },
      getCurrentDirectory: () => "/",
      getCanonicalFileName: (f) => f,
      useCaseSensitiveFileNames: () => true,
      getNewLine: () => "\n",
      fileExists: (name) => name === filename,
      readFile: (name) => (name === filename ? source : undefined),
    };
 
    const program = ts.createProgram([filename], this.compilerOptions, host);
    const emitResult = program.emit();
 
    // Collect diagnostics
    const allDiagnostics = ts
      .getPreEmitDiagnostics(program)
      .concat(emitResult.diagnostics);
 
    for (const diagnostic of allDiagnostics) {
      if (diagnostic.file && diagnostic.start !== undefined) {
        const position = diagnostic.file.getLineAndCharacterOfPosition(
          diagnostic.start
        );
        diagnostics.push({
          messageText: ts.flattenDiagnosticMessageText(
            diagnostic.messageText,
            "\n"
          ),
          line: position.line + 1,
          column: position.character + 1,
          category:
            diagnostic.category === ts.DiagnosticCategory.Error
              ? "error"
              : diagnostic.category === ts.DiagnosticCategory.Warning
                ? "warning"
                : "suggestion",
        });
      }
    }
 
    return {
      code: outputCode,
      sourceMap: outputSourceMap,
      diagnostics,
      duration: performance.now() - start,
    };
  }
}

Setting noEmitOnError: false is a deliberate UX decision. In a playground, users want to see their code run even when the type checker reports errors. A type error does not necessarily mean the code will fail at runtime. By always emitting JavaScript, we let users experiment freely while still showing type errors as diagnostics.

Type Acquisition and Library Support

A TypeScript playground is only as useful as the types it provides. Without DOM types, users cannot write document.getElementById without errors. Without library types, they cannot experiment with frameworks. Kodepad provides types through two mechanisms: bundled default libraries and on-demand type acquisition.

Default libraries are included in the build and loaded eagerly:

// src/compiler/type-loader.ts
interface TypeDefinition {
  path: string;
  content: string;
}
 
class TypeLoader {
  private loadedTypes: Map<string, string> = new Map();
  private tsDefaults = monaco.languages.typescript.typescriptDefaults;
 
  async loadDefaultLibs(): Promise<void> {
    const defaultLibs: TypeDefinition[] = [
      { path: "lib.es2020.d.ts", content: await this.fetch("/types/lib.es2020.d.ts") },
      { path: "lib.dom.d.ts", content: await this.fetch("/types/lib.dom.d.ts") },
      { path: "lib.dom.iterable.d.ts", content: await this.fetch("/types/lib.dom.iterable.d.ts") },
    ];
 
    for (const lib of defaultLibs) {
      this.tsDefaults.addExtraLib(lib.content, lib.path);
      this.loadedTypes.set(lib.path, lib.content);
    }
  }
 
  async loadPackageTypes(packageName: string): Promise<boolean> {
    const cacheKey = `@types/${packageName}`;
    if (this.loadedTypes.has(cacheKey)) return true;
 
    try {
      // Fetch types from a CDN or bundled type store
      const typesUrl = `https://cdn.jsdelivr.net/npm/@types/${packageName}/index.d.ts`;
      const content = await this.fetch(typesUrl);
 
      this.tsDefaults.addExtraLib(
        content,
        `node_modules/@types/${packageName}/index.d.ts`
      );
      this.loadedTypes.set(cacheKey, content);
      return true;
    } catch {
      return false;
    }
  }
 
  private async fetch(url: string): Promise<string> {
    const response = await globalThis.fetch(url);
    if (!response.ok) throw new Error(`Failed to fetch ${url}`);
    return response.text();
  }
}

On-demand type acquisition is triggered when the user writes an import statement. Kodepad detects import declarations in the editor and attempts to load the corresponding types:

function detectImports(source: string): string[] {
  const importRegex = /import\s+.*?\s+from\s+['"]([^'"]+)['"]/g;
  const packages: string[] = [];
  let match: RegExpExecArray | null;
 
  while ((match = importRegex.exec(source)) !== null) {
    const specifier = match[1];
    // Only handle bare specifiers (not relative paths)
    if (!specifier.startsWith(".") && !specifier.startsWith("/")) {
      const packageName = specifier.startsWith("@")
        ? specifier.split("/").slice(0, 2).join("/")
        : specifier.split("/")[0];
      packages.push(packageName);
    }
  }
 
  return [...new Set(packages)];
}

Source Map Integration for Error Reporting

When compiled JavaScript throws a runtime error in the sandbox, the error's line and column numbers refer to the compiled output, not the original TypeScript source. Source maps bridge this gap, allowing Kodepad to display errors at the correct position in the user's TypeScript code:

// src/compiler/sourcemap-resolver.ts
interface SourcePosition {
  line: number;
  column: number;
}
 
class SourceMapResolver {
  private mappings: Map<string, DecodedSourceMap> = new Map();
 
  addSourceMap(executionId: string, sourceMapJson: string): void {
    const parsed = JSON.parse(sourceMapJson);
    this.mappings.set(executionId, this.decode(parsed));
  }
 
  resolvePosition(
    executionId: string,
    jsLine: number,
    jsColumn: number
  ): SourcePosition | null {
    const map = this.mappings.get(executionId);
    if (!map) return null;
 
    // Binary search for the closest mapping
    const mappingsForLine = map.lines[jsLine - 1];
    if (!mappingsForLine) return null;
 
    let closest: MappingSegment | null = null;
    for (const segment of mappingsForLine) {
      if (segment.generatedColumn <= jsColumn) {
        closest = segment;
      } else {
        break;
      }
    }
 
    if (closest && closest.originalLine !== undefined) {
      return {
        line: closest.originalLine + 1,
        column: closest.originalColumn + 1,
      };
    }
 
    return null;
  }
 
  private decode(rawMap: RawSourceMap): DecodedSourceMap {
    // VLQ decoding of the mappings string
    // In practice, we use a library like @jridgewell/sourcemap-codec
    return decodeMappings(rawMap);
  }
}

Source maps are essential for the playground experience. Without them, a runtime error on "line 12" of the compiled JavaScript is meaningless to a user looking at TypeScript source where the corresponding code is on line 8. With source maps, the error overlay highlights the exact line in their editor.

Compiler Configuration UI

Advanced users want to experiment with different TypeScript compiler settings. Kodepad exposes a subset of compiler options through a settings panel:

interface PlaygroundCompilerOptions {
  strict: boolean;
  target: "ES2015" | "ES2020" | "ESNext";
  jsx: "react" | "react-jsx" | "preserve";
  experimentalDecorators: boolean;
  noImplicitAny: boolean;
  strictNullChecks: boolean;
}
 
const DEFAULT_OPTIONS: PlaygroundCompilerOptions = {
  strict: true,
  target: "ES2020",
  jsx: "react",
  experimentalDecorators: false,
  noImplicitAny: true,
  strictNullChecks: true,
};
 
function mapToCompilerOptions(
  options: PlaygroundCompilerOptions
): ts.CompilerOptions {
  const targetMap: Record<string, ts.ScriptTarget> = {
    ES2015: ts.ScriptTarget.ES2015,
    ES2020: ts.ScriptTarget.ES2020,
    ESNext: ts.ScriptTarget.ESNext,
  };
 
  const jsxMap: Record<string, ts.JsxEmit> = {
    react: ts.JsxEmit.React,
    "react-jsx": ts.JsxEmit.ReactJSX,
    preserve: ts.JsxEmit.Preserve,
  };
 
  return {
    target: targetMap[options.target],
    module: ts.ModuleKind.None,
    strict: options.strict,
    esModuleInterop: true,
    jsx: jsxMap[options.jsx],
    experimentalDecorators: options.experimentalDecorators,
    noImplicitAny: options.noImplicitAny,
    strictNullChecks: options.strictNullChecks,
    noEmitOnError: false,
    sourceMap: true,
  };
}

Exposing these options serves both educational and practical purposes. Users learning TypeScript can toggle strictNullChecks to understand its effect. Experienced developers can match their project's configuration to test code in a familiar environment.

Conclusion

Designing a TypeScript playground that feels native requires bringing the full power of the TypeScript compiler into the browser. This means running the compiler for both diagnostics and emit, providing type definitions for the DOM and popular libraries, mapping runtime errors back to source positions through source maps, and giving users control over compiler configuration.

The most important design principle is to never block the user. Compilation errors should be displayed as diagnostics, not gatekeepers. Type acquisition should happen in the background. Compiler configuration should have sensible defaults with optional overrides. The playground should feel like a low-friction environment for experimentation, with the type system as a helpful guide rather than a strict enforcer. This balance between rigor and flexibility is what makes a TypeScript playground genuinely useful for learning, prototyping, and sharing code.

Related Articles

business

Rapid Prototyping: From Idea to Demo in Minutes

How browser-based code playgrounds enable rapid prototyping workflows that compress the journey from concept to working demo, and why this capability is a competitive advantage for engineering teams.

8 min read
business

Code Playgrounds in Developer Education

How code playgrounds are transforming developer education by providing immediate feedback, reducing setup barriers, and enabling interactive learning experiences that scale.

8 min read
business

Developer Tools That Boost Productivity

An exploration of how browser-based developer tools like code playgrounds reduce friction in the development workflow, accelerating everything from prototyping to debugging to knowledge sharing.

7 min read