TypeScript without TypeScript

Or: Use TypeScript without Transpiling

As a colleague and I were planning a new project, we realized there were pain points in our existing projects that we wished very much to resolve (particularly an Express API written in TypeScript that pre-dated both of our start dates at the company).

The Problem

The biggest pain points we wanted to resolve were:

  • TypeScript's transpilation time slowing development
  • TypeScript's transpilation obscuring stack traces
  • TypeScript occasionally not supporting recent changes to JavaScript (for example, Array.prototype.flatMap)

The Resolution: Stop transpiling TypeScript to JavaScript

Resolving these TypeScript pain points was NOT trivial because we didn't want to simply throw it out. TypeScript provides enhancements to developer experience that we didn’t want to lose, including:

  • documentation of properties and methods of complex objects
  • test coverage that all declared types successfully pass type-checking (meaning, among other things, that your documentation correctly describes your code)
  • autocompletion hints inline in the editor
  • type mismatches surface inline in most editors, which helps catch bugs before you've even hit "Save"

Our resolution was to stop writing TypeScript while continuing to use TypeScript. In other words, instead of writing TypeScript that gets transpiled, we write plain old JavaScript and use JSDoc comments to define type information. TypeScript officially-supports this alternative way to declare type information, and it completely eliminates to the need to transpile TypeScript to JavaScript while preserving the developer enhancements I identified above.

So, how does this change resolve the pain points?

Pain Point: Transpilation Slows Development

The magnitude of this impact varies based your development environment and workflow, as well as properties of the project you’re working on (like, how much code we're talking about). As one data point, after we experimented with the no-transpilation approach, I rewrote a small project to change from TypeScript to JavaScript with JSDoc annotations. In that repo, the slowness that was most impacting my development was running the tests. (I like to run tests a lot.) Before the switch, tests took 8.5-9 s. After: 1.5-2 s.

Pain Point: Transpilation Obscuring Stack Traces

While it's possible to resolve this while still transpiling your code by enabling source map support,  removing transpilation resolves the issue completely and doesn't involve relying on either userland source map support or experimental Node core support.

Pain Point: Supporting recent changes to JavaScript

Removing transpilation and just writing JavaScript resolves this issue completely: if the version of Node that the app runs supports a JavaScript feature, you can use it. TypeScript support is irrelevant.

The flip side of this is that TypeScript does have support for some things that are not available in the version of Node we currently use (Node 12) – notably, the optional chaining operator ?., which I’m sad to lose until we upgrade to a new version of Node.

Conclusions and Recommendations

We concluded that this resolution resolved all of the pain points we identified. At this time, the only new possible pain points that we've noticed are:

  • Writing complex type declarations with JSDoc annotations (and translating existing TypeScript type declarations to JSDoc annotations) is not as well-documented as doing it in TypeScript; so far, the few times this has come-up, it's been a good signal that simplifying our code was a better solution.
  • Our existing (incorrect) usage of TypeScript modules (instead of declaration files) to share types is not compatible with plain JavaScript (without jumping through hoops that make TypeScript's type checking slower).

Note that for frontend projects, the tradeoffs are not the same:

  • Frontend projects will always transpile, even without TypeScript (for the foreseeable future, at least), so the headaches caused by transpilation cannot be avoided.
  • Source map support in browsers is more mature than it is in Node, so stack traces in frontend projects can be more easily configured to use source maps.

As a result, we have adopted this resolution, and recommend that new backend projects at our company do the same. This is the pattern I will use for all of my personal projects, as well.

Here's a bare-bones tsconfig.json you can use to try this in your JavaScript project:

  "compilerOptions": {
    /* Visit https://aka.ms/tsconfig.json to read more about this file */
    "module": "none" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
    "allowJs": true /* Allow javascript files to be compiled. */,
    "checkJs": true /* Report errors in .js files. */,
    "noEmit": true /* Do not emit outputs. */,
    "noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */,
    "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
    "skipLibCheck": true /* Skip type checking of declaration files. */,
    "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
  /* You'll need to set these to glob patterns that match your project */
  "include": ["**/*"],
  "exclude": ["coverage", "**/*.test.*"]

After adding that to your project, add TypeScript as a dev-dependency using npm install -D typescript and run tsc to check your project for TypeScript errors.

Categorized as post