Stimulus + TypeScript: A Love Story

“We resisted TypeScript in our Stimulus controllers—until it saved us from 50 runtime bugs in a week.”

Stimulus is brilliant for sprinkling interactivity without a JavaScript framework. But as our app grew, we found ourselves:

  • Guessing what this.targets included
  • Debugging undefined method calls
  • Wasting hours on typos in event names

Then we added TypeScript—and everything changed.

Here’s how to make Stimulus and TypeScript work together like soulmates, not forced partners.

1. Why TypeScript? The Pain Points It Fixes

Problem 1: Magic Strings Everywhere

Before:

// What targets exist? Guess and check!
this.targets.find("submitButton") // Error? Maybe it's "submit-btn"?

After:

// Autocomplete and type-checking
this.targets.find("submitButton") // ✅ Compiler error if misspelled

Problem 2: Untyped Event Handlers

Before:

// Hope the event has `detail`!
handleSubmit(event) {
  const data = event.detail.user // 💥 Runtime error if undefined
}

After:

interface CustomEventDetail {
  user: { id: string }
}

handleSubmit(event: CustomEvent<CustomEventDetail>) {
  const data = event.detail.user // ✅ Type-safe
}

2. The Setup (It’s Easier Than You Think)

Step 1: Install Dependencies

yarn add --dev typescript @types/stimulus @hotwired/stimulus

Step 2: Configure tsconfig.json

{
  "compilerOptions": {
    "target": "ES6",
    "module": "ESNext",
    "strict": true,
    "moduleResolution": "node",
    "esModuleInterop": true
  }
}

Step 3: Write Typed Controllers

// app/javascript/controllers/search_controller.ts
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["input", "results"]
  declare readonly inputTarget: HTMLInputElement
  declare readonly resultsTarget: HTMLElement

  search() {
    // `this.inputTarget` is now typed as HTMLInputElement!
    fetch(`/search?q=${this.inputTarget.value}`)
      .then(r => r.json())
      .then(data => {
        this.resultsTarget.innerHTML = data.html
      })
  }
}

Key Wins:

  • No more undefined target errors
  • Autocomplete for DOM methods
  • Compiler checks for event payloads

3. Advanced Patterns We Love

1. Typed Action Params

// In controller:
toggle({ params }: { params: { activeClass: string } }) {
  this.element.classList.toggle(params.activeClass)
}

<!-- In HTML: -->
<button data-action="click->menu#toggle"
        data-menu-active-class-param="is-open">
  Toggle
</button>

2. Shared Types Across Frontend/Backend

// shared/types.ts
export interface User {
  id: string
  name: string
}

// In controller:
fetchUser(): Promise<User> {
  return fetch("/current_user").then(r => r.json())
}

3. Type-Safe Global Events

// Custom event type
type CartUpdatedEvent = CustomEvent<{ items: number }>

// Dispatch with type safety
this.dispatch("cart:updated", { detail: { items: 3 } })

// Listen with type safety
window.addEventListener("cart:updated", (e: CartUpdatedEvent) => {
  console.log(e.detail.items) // ✅ Number
})

4. The Tradeoffs

⚠️ Slightly slower initial setup
⚠️ Build step required (but Vite makes this painless)
⚠️ Team learning curve if new to TypeScript

But the payoff:

  • 50% fewer runtime errors in our Stimulus code
  • Faster onboarding (types document behavior)
  • Confident refactors

5. Gradual Adoption Path

  1. Start with one controller (form_controller.ts)
  2. Add types for new controllers only
  3. Convert old controllers as you touch them

“But We’re a Small Team!”

We were too. Start small:

  1. Add TypeScript to one controller
  2. Measure time saved on debugging
  3. Let the team lobby for more

Already using Stimulus + TypeScript? Share your pro tips below!

Similar Posts