DEV Community

Alex Aslam
Alex Aslam

Posted on

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"?
Enter fullscreen mode Exit fullscreen mode

After:

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

Problem 2: Untyped Event Handlers

Before:

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

After:

interface CustomEventDetail {
  user: { id: string }
}

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

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

Step 1: Install Dependencies

yarn add --dev typescript @types/stimulus @hotwired/stimulus
Enter fullscreen mode Exit fullscreen mode

Step 2: Configure tsconfig.json

{
  "compilerOptions": {
    "target": "ES6",
    "module": "ESNext",
    "strict": true,
    "moduleResolution": "node",
    "esModuleInterop": true
  }
}
Enter fullscreen mode Exit fullscreen mode

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
      })
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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())
}
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)