Type-Casting Doubt: How A TypeScript Re-Migration Revealed The Truth About Turbo

In September 2023, David Heinemeier Hansson announced that "Turbo 8 is dropping TypeScript." This decision generated significant discussion in the development community regarding the merits of static versus dynamic typing systems. The Turbo framework represents an ideal case study because it previously underwent a full JavaScript-to-TypeScript migration before reverting back to JavaScript. This prior conversion was not merely superficial; it involved substantial refactoring throughout the codebase. This analysis presents empirical evidence regarding TypeScript's effectiveness in the context of the Turbo framework, based on a reimplementation of Turbo using TypeScript (github.com/shiftyp/ts-turbo). Contents Historical Context of TypeScript in Turbo What TypeScript Actually Fixed Null References Interface Implementation Inconsistencies Coercion Issues What TypeScript Couldn't Fix Logical Errors Browser Compatibility Issues Memory Management Issues TypeScript Done Wrong Configuration Problems Any Type Misuse Error Suppression Best Practices for TypeScript Migration Historical Context of TypeScript in Turbo DHH stated that "TypeScript just gets in the way for me," and "things that should be easy become hard, and things that are hard become any." This perspective merits examination in the specific context of Turbo's implementation. The commit history indicates that Turbo's previous TypeScript implementation was partial and inconsistent. The codebase exhibited signs of Type-Script-as-retrofit rather than TypeScript-by-design. Specifically, it showed patterns of: Extensive use of any types to bypass type checking Disabled strict null checks to avoid addressing potential null references Inconsistent application of type safety across related components What TypeScript Actually Fixed When properly implemented with strict settings, TypeScript revealed a number of genuine issues in the Turbo codebase: Null References Null reference errors can cause applications to crash with messages like "Cannot read property 'getAttribute' of null". These issues are particularly dangerous because they can work during testing but fail in production under specific conditions. They result in blank pages, non-functioning controls, or broken navigation for users. In frame controllers, element references were frequently accessed without checking for existence. Consider this innocent-looking JavaScript code from the original Turbo implementation: // From commit 9f3aad7: src/core/frames/frame_controller.js getRootLocation() { const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`) const root = meta?.content ?? "/" return expandURL(root) } What happens when this.element is null? JavaScript crashes at runtime with a null reference error. The TypeScript version in ts-turbo makes this safer: // Current implementation in ts-turbo: src/core/frames/frame_controller.ts get rootLocation(): Location { if (!this.element) return expandURL("/") const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`) const root = meta?.content ?? "/" return expandURL(root) } When this pattern appears throughout your codebase, users might experience seemingly random errors that would be difficult to reproduce and debug. Interface Implementation Inconsistencies Inconsistent interface implementations create "ghost bugs" where components work in normal conditions but fail in edge cases. These failures manifest as seemingly random crashes or functionality that works inconsistently. Interface mismatches create problems with missing parameters, unexpected return types, and dependencies on non-existent properties. JavaScript's loose nature allowed Turbo components to implement interfaces inconsistently. A particularly dangerous interface implementation issue found during the migration involves the delegate pattern and missing method implementations. Turbo uses a system where FrameElement has a delegate, but there's no explicit interface defining what methods this delegate must implement. In frame_controller.js (lines 238), we see a call to a method that may not exist: // In frame_controller.js - The method is called on the frame's delegate frame.delegate.proposeVisitIfNavigatedWithAction(frame, element, submitter) Turbo has a delegate pattern where FrameElement creates its delegate from a constructor class: // In frame_element.js - The delegate is created but with no explicit interface export class FrameElement extends HTMLElement { static delegateConstructor = undefined // Set elsewhere in the codebase constructor() { super() this.delegate = new FrameElement.delegateConstructor(this) } // The only delegate methods actually called are: connectedCallback() { this.delegate.connect() } disconnectedCallback() { this.delegate.disconnect() } attributeChangedC

Mar 30, 2025 - 02:55
 0
Type-Casting Doubt: How A TypeScript Re-Migration Revealed The Truth About Turbo

In September 2023, David Heinemeier Hansson announced that "Turbo 8 is dropping TypeScript." This decision generated significant discussion in the development community regarding the merits of static versus dynamic typing systems.

The Turbo framework represents an ideal case study because it previously underwent a full JavaScript-to-TypeScript migration before reverting back to JavaScript. This prior conversion was not merely superficial; it involved substantial refactoring throughout the codebase.

This analysis presents empirical evidence regarding TypeScript's effectiveness in the context of the Turbo framework, based on a reimplementation of Turbo using TypeScript (github.com/shiftyp/ts-turbo).

Contents

  • Historical Context of TypeScript in Turbo
  • What TypeScript Actually Fixed
    • Null References
    • Interface Implementation Inconsistencies
    • Coercion Issues
  • What TypeScript Couldn't Fix
    • Logical Errors
    • Browser Compatibility Issues
    • Memory Management Issues
  • TypeScript Done Wrong
    • Configuration Problems
    • Any Type Misuse
    • Error Suppression
  • Best Practices for TypeScript Migration

Historical Context of TypeScript in Turbo

DHH stated that "TypeScript just gets in the way for me," and "things that should be easy become hard, and things that are hard become any." This perspective merits examination in the specific context of Turbo's implementation.

The commit history indicates that Turbo's previous TypeScript implementation was partial and inconsistent. The codebase exhibited signs of Type-Script-as-retrofit rather than TypeScript-by-design. Specifically, it showed patterns of:

  1. Extensive use of any types to bypass type checking
  2. Disabled strict null checks to avoid addressing potential null references
  3. Inconsistent application of type safety across related components

What TypeScript Actually Fixed

When properly implemented with strict settings, TypeScript revealed a number of genuine issues in the Turbo codebase:

Null References

Null reference errors can cause applications to crash with messages like "Cannot read property 'getAttribute' of null". These issues are particularly dangerous because they can work during testing but fail in production under specific conditions. They result in blank pages, non-functioning controls, or broken navigation for users.

In frame controllers, element references were frequently accessed without checking for existence. Consider this innocent-looking JavaScript code from the original Turbo implementation:

// From commit 9f3aad7: src/core/frames/frame_controller.js
getRootLocation() {
  const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`)
  const root = meta?.content ?? "/"
  return expandURL(root)
}

What happens when this.element is null? JavaScript crashes at runtime with a null reference error. The TypeScript version in ts-turbo makes this safer:

// Current implementation in ts-turbo: src/core/frames/frame_controller.ts
get rootLocation(): Location {
  if (!this.element) return expandURL("/")
  const meta = this.element.ownerDocument.querySelector<HTMLMetaElement>(`meta[name="turbo-root"]`)
  const root = meta?.content ?? "/"
  return expandURL(root)
}

When this pattern appears throughout your codebase, users might experience seemingly random errors that would be difficult to reproduce and debug.

Interface Implementation Inconsistencies

Inconsistent interface implementations create "ghost bugs" where components work in normal conditions but fail in edge cases. These failures manifest as seemingly random crashes or functionality that works inconsistently. Interface mismatches create problems with missing parameters, unexpected return types, and dependencies on non-existent properties.

JavaScript's loose nature allowed Turbo components to implement interfaces inconsistently. A particularly dangerous interface implementation issue found during the migration involves the delegate pattern and missing method implementations. Turbo uses a system where FrameElement has a delegate, but there's no explicit interface defining what methods this delegate must implement.

In frame_controller.js (lines 238), we see a call to a method that may not exist:

// In frame_controller.js - The method is called on the frame's delegate
frame.delegate.proposeVisitIfNavigatedWithAction(frame, element, submitter)

Turbo has a delegate pattern where FrameElement creates its delegate from a constructor class:

// In frame_element.js - The delegate is created but with no explicit interface
export class FrameElement extends HTMLElement {
  static delegateConstructor = undefined  // Set elsewhere in the codebase

  constructor() {
    super()
    this.delegate = new FrameElement.delegateConstructor(this)
  }

  // The only delegate methods actually called are:
  connectedCallback() {
    this.delegate.connect()
  }

  disconnectedCallback() {
    this.delegate.disconnect()
  }

  attributeChangedCallback(name) {
    if (name == "loading") {
      this.delegate.loadingStyleChanged()
    } else if (name == "src") {
      this.delegate.sourceURLChanged()
    } else {
      this.delegate.disabledChanged()
    }
  }
}

The problem is that in JavaScript, there's no explicit interface requiring proposeVisitIfNavigatedWithAction to be implemented by whatever class gets assigned to FrameElement.delegateConstructor. This creates an implicit, undocumented contract between components.

This is a particularly severe issue that would cause the application to crash with a "method not found" error when a frame navigation occurs. Since the error only happens during specific navigation sequences, it would be difficult to detect during testing. In TypeScript, this problem is solved by requiring explicit interfaces that define exactly what methods must be implemented.

Confusing Type Coercion

JavaScript's implicit type coercion creates bugs that appear to work correctly but produce incorrect results under specific conditions. These issues are difficult to debug because they often appear to be environmental or timing-related problems.

JavaScript's implicit type coercion created subtle bugs that often went unnoticed until they caused production failures. In src/core/drive/history.js we find code that raises questions about intent:

// Original JavaScript implementation - implicit type coercion
restore(position) {
  if (this.restorationIdentifier) {
    history.replaceState(history.state, "", location.toString())
    this.location = location.href
    history.pushState(history.state, "", position)
    // Number comparison with string using == (not ===)
    if (this.currentIndex == 0) {
      window.scrollTo(0, 0)
    }
  }
}

The TypeScript implementation in ts-turbo makes the comparison explicit and prevents potential coercion issues:

// TypeScript implementation in ts-turbo
restore(position: URL): void {
  if (this.restorationIdentifier) {
    history.replaceState(history.state, "", location.toString())
    this.location = location.href
    history.pushState(history.state, "", position)
    // Explicit comparison with proper typing
    if (this.currentIndex === 0) {
      window.scrollTo(0, 0)
    }
  }
}

Is this checking for equality or accidentally converting types?
return this.pageLoaded || document.readyState == "complete". This might execute when you don't expect it to due to coercion. This pattern was found throughout the history management module. The deeper issue is more complex than it appears:

  1. Mismatched comparison patterns: Throughout the codebase, comparisons mixed strict equality (===) and loose equality (==), creating inconsistent behavior. document.readyState == "complete" uses loose equality while this.restorationIdentifier === identifier uses strict equality elsewhere.

  2. State tracking issues: The history system tracks various types of data: URL paths (strings), restoration identifiers (mixed types), and state objects. When compared with loose equality, URL paths like "/404" could accidentally match numeric identifiers.

  3. Browser implementation differences: Browsers handle history state objects inconsistently - some preserve types exactly, others serialize and deserialize (changing types), and Safari might convert numeric strings to numbers in some contexts.

Another example comes from the form submission handling:

In the form submission handling, we see problematic implicit conversions:

// Actual code from form_submission.js after TypeScript removal (commit 9f3aad7772ba8ef4080538e4e5fb175a8ad550f1)
get method() {
  const method = this.submitter?.getAttribute("formmethod") || this.formElement.getAttribute("method") || ""
  return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get
}

This pattern creates potential issues where an empty string method attribute (which is falsy in JavaScript) would be treated differently than a non-existent method, when both should result in the default method

This form submission example illustrates multiple coercion challenges that affected real user interactions:

  1. Attribute handling inconsistencies: getAttribute() returns null for non-existent attributes while empty attributes return "". The logical OR (||) treats both as falsy, but these cases should be handled differently.

  2. Form method determination: Web forms behave differently based on GET vs POST methods, affecting navigation, caching, and data handling. The wrong method can cause data loss or security issues.

  3. Cascading defaults problem: The chain of OR operations creates complex logic paths where getAttribute() might return null, undefined, or empty string, each requiring different handling.

TypeScript's strict checking would have enforced explicit handling of these different cases.

TypeScript flagged dozens of instances where loose equality checks (==) were used instead of strict equality (===), where string values were implicitly converted to numbers, and where empty strings were treated as falsy values.

What TypeScript Couldn't Fix

Static typing isn't a silver bullet, and certain categories of bugs remained immune to TypeScript's protection:

Logical Errors Persisted

While TypeScript catches type-related errors effectively, it offers little protection against logical errors in algorithm design and control flow. Users might experience inconsistent application state, operations executing in the wrong order, and race conditions. These bugs often work during testing but fail in production, particularly with asynchronous operations where timing becomes critical.

The migration revealed that TypeScript couldn't prevent flawed algorithms or incorrect control flow. For example, in the stream message renderer implementation, even with TypeScript, logical errors in the processing sequence remained:

// From src/core/drive/navigator.ts in the TypeScript codebase
visit(location: URL, options: Partial<VisitOptions> = {}) {
  this.navigator.visit(location, options)
}

navigate(event: Event) {
  if (this.isHashChangeEvent(event)) {
    this.navigator.update(this.location, "replace")
  } else {
    // Event and navigation handling run in separate async paths
    // TypeScript can't detect logical flow problems between these methods
    const linkClicked = this.getVisitOptions(event)
    this.visit(linkClicked.url, linkClicked.options)
  }
}

This example illustrates a logical flow error that TypeScript cannot detect:

In Turbo's navigation system, there are multiple asynchronous execution paths:

  1. The navigate() method responds to click events
  2. getVisitOptions() performs DOM operations (reading data attributes) that are implicitly async
  3. visit() initiates async operations (network requests, history API calls)
  4. Multiple event listeners at different levels (window, document, elements) can trigger competing navigation requests simultaneously

The race condition occurs because between events, the URL can be modified by other code, multiple clicks create competing navigation requests, and these events execute in different event loop cycles that TypeScript cannot analyze.

These timing issues led to bugs where users would click a link but end up at a different URL, back/forward navigation would break after rapid interactions, and the browser and Turbo would get into inconsistent states about the "current" URL.

TypeScript can only validate parameter and return types, but cannot detect timing-related problems across event loops, verify coordination between async execution paths, or ensure state consistency in a multi-event environment.

These issues required specialized testing (like rapid-click testing) and event sequence analysis during code reviews - things that static type checking cannot provide.

Memory Management Still Required Discipline

Memory management issues cause gradual performance degradation, increasing memory consumption, and browser crashes after extended use. Applications become slower over time with no obvious solution except reloading. These problems are especially severe in single-page applications where resources aren't naturally cleaned up by page navigation.

Some of the most insidious bugs in Turbo involved memory management. Analysis of stream_source_element.js revealed significant issues:

// From commit 9f3aad7: src/elements/stream_source_element.js
export class StreamSourceElement extends HTMLElement {
  streamSource = null

  connectedCallback() {
    this.streamSource = this.src.match(/^ws{1,2}:/) ? new WebSocket(this.src) : new EventSource(this.src)

    connectStreamSource(this.streamSource)
  }

  disconnectedCallback() {
    if (this.streamSource) {
      disconnectStreamSource(this.streamSource)
    }
  }

  get src() {
    return this.getAttribute("src") || ""
  }
}

TypeScript can't catch memory leaks if disconnect logic is incomplete. The pattern above requires careful tracking of all resources across multiple components.

To better understand the context, let's look at my TypeScript implementation and several key components involved:

// src/elements/stream_source_element.ts (TypeScript implementation)
export class StreamSourceElement extends HTMLElement {
  streamSource: StreamSource | null = null

  connectedCallback() {
    // Creates either WebSocket or EventSource based on URL pattern
    this.streamSource = this.src.match(/^ws{1,2}:/) ? 
      new WebSocket(this.src) : new EventSource(this.src)

    connectStreamSource(this.streamSource)
  }

  disconnectedCallback() {
    if (this.streamSource) {
      disconnectStreamSource(this.streamSource)
    }
  }
}
// Example from observer pattern in frame_controller.ts
export class FrameController implements LinkClickObserverDelegate, 
  AppearanceObserverDelegate<FrameElement>, FormSubmitObserverDelegate {
  // Multiple observers that need to be started/stopped at the right times
  readonly appearanceObserver: AppearanceObserver<FrameElement>
  readonly formLinkClickObserver: FormLinkClickObserver
  readonly formSubmitObserver: FormSubmitObserver

  constructor() {
    // Create observers that register event listeners
    this.appearanceObserver = new AppearanceObserver(this, this.element)
    this.formLinkClickObserver = new FormLinkClickObserver(this, this.element)
    this.formSubmitObserver = new FormSubmitObserver(this, this.element)
  }

  connect() {
    this.appearanceObserver.start()
    this.formLinkClickObserver.start()
    this.formSubmitObserver.start()
  }

  disconnect() {
    this.appearanceObserver.stop()
    this.formLinkClickObserver.stop()
    this.formSubmitObserver.stop()
  }
}

This example represents a much larger memory management challenge in Turbo. The deeper context reveals:

  1. Multi-layer resource ownership: The StreamSource element creates controllers that register observers, attach event listeners, and establish network connections. In the FrameController example, three different observer types are instantiated, each requiring proper start/stop coordination.

  2. Disconnection gaps: The disconnectedCallback only calls hostDisconnected(), but that method might not clean up all event listeners, verify closed connections, or handle interruptions during active operations. TypeScript can't enforce proper cleanup at runtime.

  3. Cross-component coordination: TypeScript has no way to ensure resources allocated by one component are cleaned up by another, observers are properly stopped, or event listeners are removed correctly. TypeScript can verify methods exist but cannot enforce they're called at the right time or sequence.

Typescript couldn't solve these fundamental design challenges because memory management in web components requires a holistic approach rather than just type checking. The issue wasn't just about having the correct type signatures, but about enforcing a complete resource lifecycle management system.

Other memory issues included event listeners not being properly removed, circular references preventing garbage collection, and resources not being released after use.

TypeScript offered little protection against these problems. Memory management in JavaScript requires disciplined coding practices regardless of the type system, and TypeScript's compile-time checks couldn't enforce proper cleanup.

TypeScript Done Wrong

A partial, inconsistent TypeScript implementation creates a situation where a codebase incurs all the costs of TypeScript (build complexity, learning curve, type definitions) while receiving few of its benefits. This can create a false sense of security and add friction to development, with team members spending time fighting the type system rather than leveraging it.

Perhaps the most revealing finding was that Turbo never truly implemented TypeScript correctly in the first place.

Configuration Shortcomings

The TypeScript configuration used in previous attempts was fundamentally flawed. From tsconfig.json prior to removal in commit 0826b8152c0e97f19d459c1a1c364fa89cc62829

{
  "compilerOptions": {
    "esModuleInterop": true,
    "lib": [ "dom", "dom.iterable", "esnext" ],
    "module": "es2015",
    "moduleResolution": "node",
    "noUnusedLocals": true,
    "rootDir": "src",
    "strict": true,
    "target": "es2017",
    "noEmit": true,
    "removeComments": true,
    "skipLibCheck": true,
    "isolatedModules": true
  },
  "exclude": [ "dist", "src/tests/fixtures", "playwright.config.ts" ]
}

Key issues included:

  • The tsconfig.json lacked crucial strict settings despite claiming to be "strict"
  • Important type checking flags like strictNullChecks were functionally disabled through type assertions
  • The compiler was configured to be permissive rather than strict in practice

The "Any" Escape Hatch

When TypeScript flagged potential issues, the common solution was to use the any type to silence the compiler rather than fix the underlying problem:

// From stream_message.ts prior to removal
// Instead of properly typing this:
function processMessage(message: any) {
  // Access properties without type checking
  if (message.content) {
    // Type safety completely bypassed
  }
}
// From error_renderer.ts prior to removal
function renderError(error: any) {
  // Using any to avoid properly modeling the error hierarchy
  return error.message || "Unknown error"
}
// Using any to bypass element type checking
function processNode(node: any) {
  // Directly accessing element properties without verification
  if (node.id && node.hasAttribute) {
    // This crashes if node is a Text node, not an Element
  }
}
// Promise error handling with any type
async function handleRequest(request: any): Promise<any> {
  try {
    // Generic error handling that loses error type information
    return await fetch(request)
  } catch (error: any) {
    console.error(error.message) // May fail if error isn't an Error object
  }
}

This pattern appeared throughout the codebase, effectively neutralizing TypeScript's benefits.

What This Means for the TypeScript vs. JavaScript Debate

DHH's frustration with TypeScript makes more sense when viewed through this lens. If your experience with TypeScript involves fighting a poorly configured system, inconsistent type enforcement, and partial implementations, then you're getting all of the friction with few of the benefits.

A lint check on the code right before TypeScript removal revealed 36 instances of the any type across the codebase. These were found in critical files like history management, rendering, HTTP request handling, and DOM manipulation.

TypeScript was implemented with significant escape hatches that undermined its safety benefits. Interestingly, while there were 36 instances of any types, there were no instances of directive comments like @ts-ignore, @ts-expect-error, or @ts-nocheck in the codebase. This suggests that developers were primarily using the any type as their main escape hatch rather than suppressing specific errors with directives.

This pattern reveals a TypeScript implementation that was likely using loose configuration settings rather than strict mode, with developers defaulting to any when encountering typing challenges instead of properly solving type issues. The result was a codebase with all of TypeScript's friction but few of its safety benefits.

However, this doesn't mean TypeScript itself is flawed. The evidence from the Turbo migration suggests that TypeScript can be valuable when:

  1. It's implemented completely and consistently
  2. Strict compiler settings are enabled from the start
  3. Type definitions are treated as design tools, not afterthoughts
  4. The escape hatches (any, type assertions) are used sparingly

Lessons From the Re-Migration

My experience remigrating Turbo to TypeScript (available at github.com/shiftyp/ts-turbo) has given me a unique perspective on this debate. By applying TypeScript properly—with strict null checks enabled, minimal use of any, and thorough interface definitions—I was able to uncover and fix numerous issues that had previously gone undetected.

The re-migration effort revealed several key improvements that TypeScript can bring when implemented correctly:

1. Explicit Interface Contracts

In the original codebase, interfaces were implicit. Looking at the remigrated session.ts file, proper interface definitions now explicitly state the contract between components:

// From src/core/session.ts in the remigration
interface PageViewDelegate {
  allowsImmediateRender({ element }: { element: Element }, options: RenderOptions): boolean;
}

interface LinkPrefetchDelegate {
  canPrefetchRequestToLocation(link: HTMLAnchorElement, location: URL): boolean;
}

export class Session implements PageViewDelegate, LinkPrefetchDelegate, FrameRedirectorSession {
  // Implementation now contractually bound to fulfill these interfaces
}

This pattern ensures that when one component expects another to have certain capabilities, that contract is verified at compile time rather than failing mysteriously at runtime.

2. Exhaustive State Handling

Form submissions in Turbo can exist in various states. The re-migration properly types these states using TypeScript's const assertions:

// From src/core/drive/form_submission.ts in the remigration
export const FormSubmissionState = {
  initialized: "initialized",
  requesting: "requesting",
  waiting: "waiting",
  receiving: "receiving",
  stopping: "stopping",
  stopped: "stopped"
} as const

export type FormSubmissionStateType = typeof FormSubmissionState[keyof typeof FormSubmissionState]

This pattern ensures that state transitions are checked exhaustively, preventing invalid states that could lead to runtime errors.

3. Browser Compatibility Safeguards

In the remigration, TypeScript forces explicit checks:

// From src/core/drive/history.ts in the remigration
assumeControlOfScrollRestoration() {
  if ("scrollRestoration" in history) {
    this.previousScrollRestoration = history.scrollRestoration
    history.scrollRestoration = "manual"
  }
}

By making these checks explicit in the type system, it's impossible to accidentally access APIs that might not exist in all browsers without first verifying their presence.

4. Form Data Safety

There were several issues with null references when processing form data. The remigration adds proper null checking:

// From src/core/drive/form_submission.ts in the remigration
buildFormData(formElement: HTMLFormElement, submitter?: HTMLElement): FormData | URLSearchParams {
  const formData = new FormData(formElement)
  const name = submitter?.getAttribute("name")
  const value = submitter?.getAttribute("value")

  if (name && value && formData.get(name) !== value) {
    formData.append(name, value)
  }

  return formData
}

This pattern of optional chaining (?.) combined with explicit null checks ensures that form processing is robust against missing data.

Conclusion

The Turbo TypeScript migration offers a nuanced perspective on the JavaScript vs. TypeScript debate. TypeScript isn't inherently good or bad—its value depends entirely on implementation. The remigration efforts have tangibly demonstrated both the costs and benefits of TypeScript when properly applied.

The question isn't "Should you use TypeScript or JavaScript?" but rather "Are you willing to implement TypeScript properly?" If you're going to disable strict null checks, use any types liberally, and only partially convert your codebase, you might be better off with JavaScript's simplicity.

DHH's announcement about dropping TypeScript from Turbo 8 might be less about TypeScript itself and more about recognizing that Turbo never gave TypeScript a fair chance to begin with. The history of previous attempts shows a pattern of half-measures and workarounds rather than embracing what TypeScript had to offer.

My re-migration project took a different approach. Rather than treating TypeScript as an afterthought or a simple type annotation layer, I integrated it as a fundamental design tool. By defining precise interfaces first and then implementing against those interfaces, TypeScript became a collaborator in the development process rather than an obstacle to be worked around. The result was a more robust codebase with fewer edge cases and more predictable behavior.

What's particularly revealing is how many issues were resolved not by adding complex type annotations, but by the simple act of forcing explicit handling of edge cases. For instance, many potential errors were eliminated by adding a few strategic null checks that TypeScript required but JavaScript didn't enforce.

Perhaps the right move isn't abandoning TypeScript, but implementing it correctly. Or perhaps, as DHH suggests, JavaScript itself has evolved enough that for some teams and projects, the additional safety net of TypeScript simply isn't worth the friction it introduces.

What's your experience? Has TypeScript saved your project from critical bugs, or has it primarily introduced friction? The debate continues, but hopefully with more nuance and less zealotry than before.