Skip to content

Stimulus TypeScript — Typed Controllers & Complete Guide

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you'll learn about Stimulus TypeScript. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Stimulus TypeScript provides type-safe controllers with typed targets, values, classes, and outlets, catching errors at compile time instead of runtime.

What You'll Learn

  • Setting up Stimulus with TypeScript
  • Typing controller classes and lifecycle methods
  • Typing targets, values, classes, and outlets
  • Creating reusable type definitions
  • Using generics for flexible controllers
  • Practical patterns for type-safe Stimulus development

Why It Matters

As Stimulus applications grow, untyped JavaScript controllers become harder to refactor. Misspelled target names, wrong value types, and missing outlet methods cause runtime errors that TypeScript catches during development. In the Doda Browser extension, TypeScript ensures that settings panel controllers, search components, and modal managers are type-safe across the entire codebase.

Learning Path

flowchart LR
  A[Lazy Loading] --> B[TypeScript]
  B --> C[Testing]
  C --> D[Project]
  B --> E[Real Projects:
DodaTech Tools] style B fill:#4f46e5,color:#fff,stroke:#4f46e5,stroke-width:2px style E fill:#059669,color:#fff

Setup

Installation

npm install @hotwired/stimulus
npm install -D typescript @types/node

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ES2020",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["**/*.ts"]
}

Basic Typed Controller

import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
  connect(): void {
    this.element.textContent = 'Hello from TypeScript!';
  }

  greet(event: MouseEvent): void {
    console.log('Button clicked!', event);
  }
}

Typing Targets

Stimulus provides a Targets utility type for typed target access:

import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
  static targets = ['name', 'email', 'submit'];

  declare readonly nameTarget: HTMLInputElement;
  declare readonly nameTargets: HTMLInputElement[];
  declare readonly hasNameTarget: boolean;

  declare readonly emailTarget: HTMLInputElement;
  declare readonly emailTargets: HTMLInputElement[];
  declare readonly hasEmailTarget: boolean;

  declare readonly submitTarget: HTMLButtonElement;

  connect(): void {
    this.nameTarget.focus();
  }

  validate(): void {
    if (!this.nameTarget.value.trim()) {
      this.nameTarget.classList.add('error');
    }
  }
}

Reusable Target Types

import { Controller } from '@hotwired/stimulus';

type Target<T> = {
  readonly [K in keyof T as K extends string ? `${K & string}Target` : never]: HTMLElement;
  readonly [K in keyof T as K extends string ? `${K & string}Targets` : never]: HTMLElement[];
  readonly [K in keyof T as K extends string ? `has${Capitalize<K & string>}Target` : never]: boolean;
};

type FormTargets = Target<{
  name: HTMLInputElement;
  email: HTMLInputElement;
  submit: HTMLButtonElement;
}>;

class FormController extends Controller {
  static targets = ['name', 'email', 'submit'];

  declare nameTarget: HTMLInputElement;
  declare nameTargets: HTMLInputElement[];
  declare hasNameTarget: boolean;

  declare emailTarget: HTMLInputElement;
  declare submitTarget: HTMLButtonElement;
}

Typing Values

Values need typed declarations matching the static definition:

import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
  static values = {
    interval: { type: Number, default: 1000 },
    autostart: Boolean,
    label: String,
    items: Array,
    config: Object
  };

  declare readonly intervalValue: number;
  declare readonly hasIntervalValue: boolean;
  declare readonly autostartValue: boolean;
  declare readonly labelValue: string;
  declare readonly itemsValue: unknown[];
  declare readonly configValue: Record<string, unknown>;

  connect(): void {
    if (this.autostartValue) {
      this.startTimer();
    }
  }

  startTimer(): void {
    console.log(`Starting timer with ${this.intervalValue}ms interval`);
  }
}

Value Change Callbacks

import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
  static values = {
    count: { type: Number, default: 0 },
    active: Boolean
  };

  declare readonly countValue: number;
  declare readonly activeValue: boolean;

  countValueChanged(current: number, previous: number | undefined): void {
    console.log(`Count changed from ${previous} to ${current}`);
    this.element.textContent = String(current);
  }

  activeValueChanged(current: boolean): void {
    this.element.classList.toggle('active', current);
  }
}

Typing Classes

import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
  static classes = ['active', 'inactive', 'loading'];

  declare readonly activeClass: string;
  declare readonly hasActiveClass: boolean;

  declare readonly inactiveClass: string;
  declare readonly hasInactiveClass: boolean;

  declare readonly loadingClass: string;
  declare readonly hasLoadingClass: boolean;

  toggle(): void {
    if (this.hasActiveClass) {
      this.element.classList.toggle(this.activeClass);
    }
  }

  setLoading(isLoading: boolean): void {
    if (isLoading && this.hasLoadingClass) {
      this.element.classList.add(this.loadingClass);
    } else if (this.hasLoadingClass) {
      this.element.classList.remove(this.loadingClass);
    }
  }
}

Typing Outlets

import { Controller } from '@hotwired/stimulus';

interface ResultsController extends Controller {
  render(items: unknown[]): void;
  showLoading(): void;
  showEmpty(): void;
  showError(message: string): void;
}

export default class extends Controller {
  static outlets = ['results', 'pagination'];

  declare readonly resultsOutlet: ResultsController;
  declare readonly resultsOutlets: ResultsController[];
  declare readonly hasResultsOutlet: boolean;

  declare readonly paginationOutlet: Controller;
  declare readonly paginationOutlets: Controller[];

  async search(query: string): Promise<void> {
    if (!this.hasResultsOutlet) return;

    this.resultsOutlet.showLoading();

    try {
      const data = await this.fetchResults(query);
      this.resultsOutlet.render(data.items);
    } catch (error) {
      this.resultsOutlet.showError(String(error));
    }
  }

  private async fetchResults(query: string): Promise<{ items: unknown[] }> {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    return response.json();
  }
}

Complete Typed Example

import { Controller } from '@hotwired/stimulus';

interface Task {
  id: number;
  title: string;
  completed: boolean;
}

interface TaskListOutlet extends Controller {
  addTask(task: Task): void;
  removeTask(id: number): void;
}

export default class extends Controller {
  static targets = ['input', 'addButton'];
  static values = {
    apiEndpoint: String,
    priority: { type: String, default: 'normal' }
  };
  static classes = ['loading', 'error'];
  static outlets = ['taskList'];

  declare readonly inputTarget: HTMLInputElement;
  declare readonly hasInputTarget: boolean;
  declare readonly addButtonTarget: HTMLButtonElement;

  declare readonly apiEndpointValue: string;
  declare readonly priorityValue: 'low' | 'normal' | 'high';

  declare readonly loadingClass: string;
  declare readonly hasLoadingClass: boolean;
  declare readonly errorClass: string;

  declare readonly taskListOutlet: TaskListOutlet;
  declare readonly hasTaskListOutlet: boolean;

  connect(): void {
    this.inputTarget.addEventListener('keydown', (event: KeyboardEvent) => {
      if (event.key === 'Enter') {
        this.add();
      }
    });
  }

  async add(): Promise<void> {
    const title = this.inputTarget.value.trim();
    if (!title) return;

    this.setLoading(true);

    try {
      const task = await this.createTask(title);
      this.taskListOutlet?.addTask(task);
      this.inputTarget.value = '';
    } catch (error) {
      this.showError(String(error));
    } finally {
      this.setLoading(false);
    }
  }

  private async createTask(title: string): Promise<Task> {
    const response = await fetch(this.apiEndpointValue, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title, priority: this.priorityValue })
    });

    if (!response.ok) {
      throw new Error('Failed to create task');
    }

    return response.json();
  }

  private setLoading(isLoading: boolean): void {
    if (!this.hasLoadingClass) return;
    this.addButtonTarget.classList.toggle(this.loadingClass, isLoading);
    this.addButtonTarget.disabled = isLoading;
  }

  private showError(message: string): void {
    if (this.hasErrorClass) {
      this.element.classList.add(this.errorClass);
    }
    console.error(message);
  }
}

Common Mistakes

1. Using declare Without Matching static Declaration

// ❌ Missing static declaration
declare readonly nameTarget: HTMLInputElement;

// ✅ Must have matching static
static targets = ['name'];
declare readonly nameTarget: HTMLInputElement;

2. Incorrect Value Type Declaration

// ❌ Value type doesn't match static definition
static values = { count: Number };
declare readonly countValue: string; // Should be number

// ✅ Match the type
declare readonly countValue: number;

3. Not Declaring Optional Outlets

// ❌ Assumes outlet always exists
this.resultsOutlet.render(data);

// ✅ Handle missing outlet
if (this.hasResultsOutlet) {
  this.resultsOutlet.render(data);
}

4. Wrong Event Type in Action Methods

// For keyboard events, use KeyboardEvent, not MouseEvent
handleKeydown(event: KeyboardEvent): void {
  if (event.key === 'Enter') { }
}

5. Forgetting to Import Controller

// ❌ Missing import
export default class extends Controller { }

// ✅ Import Controller base class
import { Controller } from '@hotwired/stimulus';

Practice Questions

1. How do you declare a typed target in Stimulus with TypeScript?

Use static targets = ['name'] and declare readonly nameTarget: HTMLInputElement to get type safety on target access.

2. What is the benefit of typing value change callbacks?

TypeScript ensures the current and previous parameters have the correct types, preventing accidental type mismatches in value change handlers.

3. How do you type an outlet for a specific controller interface?

Create an interface extending Controller with the methods the outlet exposes, then declare declare readonly nameOutlet: InterfaceType.

4. Why should you use declare for target/value/class/outlet properties?

declare tells TypeScript that these properties exist at runtime (added by Stimulus) without requiring initialization in the constructor, avoiding unnecessary property initialization.

Challenge

Convert the search-form controller from the outlets tutorial to TypeScript. Add proper types for all targets, values, classes, outlets, and methods.

FAQ

### Do I need TypeScript to use Stimulus?

No. Stimulus works perfectly with plain JavaScript. TypeScript is optional but recommended for larger codebases.

What TypeScript version is compatible with Stimulus?

Stimulus supports TypeScript 4.0 and above. The declare property syntax for targets requires TypeScript 3.7+.

How do I configure TypeScript for a Stimulus project using Webpack?

Use ts-loader or babel-loader with @babel/preset-typescript. Configure moduleResolution: 'bundler' in tsconfig.json for proper module resolution.

Can I use Stimulus types with Esbuild or Vite?

Yes. Both ESBuild and Vite support TypeScript natively. Configure them to use the ESM module format for Stimulus imports.

What's Next

Topic Description
{{< ref "stimulus-testing" >}} Testing Stimulus controllers with Jest and DOM testing
{{< ref "stimulus-project" >}} Build a complete Stimulus application from scratch
TypeScript Handbook Review TypeScript fundamentals and advanced types
JavaScript ES2020 Modern JavaScript features used in Stimulus

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. This TypeScript tutorial powers the type-safe controller architecture in the Doda Browser extension.

What's Next

Congratulations on completing this Stimulus TypeScript tutorial! Here's where to go from here:

  • Practice daily — Consistency is more important than long study sessions
  • Build a project — Apply what you learned by building something real
  • Explore related topics — Check out other tutorials in the same category
  • Join the community — Discuss with other learners and share your progress

Remember: every expert was once a beginner. Keep coding!

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro