I ship both languages, sometimes in the same repository. Three sibling Apify actors live in one monorepo. One is strict TypeScript with noUncheckedIndexedAccess on, one is strict TypeScript with it off, and one is 22 files of plain JavaScript. None of that was taste. Each one got the amount of checking its data earns. Here's the rule I use, and the 2026 facts that make it hold.

Where TypeScript's cost went: in 2020 the compiler sat on the run path; in 2026 the runtime strips types and the compiler only checks

What changed in 2026?

The cost side of the TypeScript trade collapsed. Three releases did it, and the last two landed this year.

TypeScript 6.0 shipped in March as the last release built on the JavaScript codebase. It turned strict on by default and started retiring target: es5 along with the AMD and UMD module formats. Start a project today and you get strict mode without asking for it.

TypeScript 7.0 shipped on July 8 as the native Go port, the one that spent a year as "Project Corsa". Microsoft's numbers on real repositories: VS Code type-checks 11.9x faster, Sentry 8.9x, Playwright 8.7x. A check that took a minute now takes seconds. The catch as of 7.0 is that the stable programmatic API isn't there yet, so Vue, Svelte, Astro and Angular template tooling stay on 6.x until 7.1.

Node stopped needing a build. Type stripping went on by default in Node 23.6, was backported to 22.18, and reached stable in 24.12 in December 2025. It's a 2025 milestone that every LTS line carries in 2026. This runs today on Node 24:

node app.ts
      

There's one constraint. Stripping erases annotations and never generates code, so enums, namespaces with runtime code, parameter properties and import = are out. TypeScript has a flag that rejects exactly that syntax at check time:

// tsconfig.json
      {
        "compilerOptions": {
          "erasableSyntaxOnly": true,
          "verbatimModuleSyntax": true,
          "module": "nodenext",
          "target": "esnext"
        }
      }
      

Deno and Bun ran .ts natively already. So the "TypeScript means a compile step" argument is gone on every mainstream runtime. The compiler still exists, but it moved off the run path. It checks in your editor and in CI, and it emits nothing.

What didn't die is the other cost. A dependency with bad or missing types still drags you into any casts, and a clever generic written by someone else is a worse read than the JavaScript it replaced. That cost is real and it isn't going anywhere. It just isn't a build-step argument any more.

One more data point. GitHub's Octoverse 2025 report put TypeScript at number one on the platform by monthly contributors, ahead of Python and JavaScript, and tied the jump to AI-assisted coding. A type checker reads every line an agent writes. No human reviewer does that.

When does TypeScript pay for itself?

TypeScript pays for itself the moment a data shape crosses a boundary: an API response, a message bus, storage, a form, a config file. Inside one function, types are documentation. Across a boundary, they are a contract that a machine enforces.

My clearest example is a Chrome extension, Nudge, an open-source app blocker. A content script talks to a service worker through chrome.runtime.sendMessage, and that API is typed any in both directions. In the first version, a handler that returned the wrong shape produced undefined three screens away, at runtime, on someone else's machine.

The fix was a discriminated union for requests and a map from request type to response type:

export type Request =
        | { type: 'GET_BLOCK_CONTEXT'; target: string }
        | { type: 'COMPLETE_PAUSE'; target: string }
        | { type: 'ADD_SITE'; domain: string; mode: SiteMode; delaySeconds: number }
        | { type: 'GET_SETTINGS' };

      export interface ResponseMap {
        GET_BLOCK_CONTEXT: BlockContext;
        COMPLETE_PAUSE: GrantResult;
        ADD_SITE: { ok: boolean; reason?: string };
        GET_SETTINGS: NudgeSettings;
      }

      export type ResponseFor<T extends Request['type']> = ResponseMap[T];

      export async function send<T extends Request>(
        request: T,
      ): Promise<ResponseFor<T['type']>> {
        return (await chrome.runtime.sendMessage(request)) as ResponseFor<T['type']>;
      }
      

Now send({ type: 'GET_SETTINGS' }) resolves to NudgeSettings and nothing else. Add a request variant without a response entry and the build fails. The mistake moved from a user's runtime to my editor, in twenty lines.

The cast in send is load-bearing and deliberate. sendMessage returns any, so one assertion in one wrapper buys type safety at every call site. That's the trade, made once, in a file I can audit.

The second setting worth its cost is noUncheckedIndexedAccess. It makes arr[i] and map[key] type as T | undefined, which is what they are. This line looks the way it does because the compiler refused the version without the ?? 0:

hourly[hour] = (hourly[hour] ?? 0) + seconds;
      

In JavaScript the missing hour is undefined, adding to it gives NaN, and the chart renders a gap that nobody reports. Of my three actors, the one with this flag off is not a decision. It's an oversight, and it's now a ticket.

Types stop at the runtime boundary, though. A fetch response typed as Review is still whatever the server sent. I covered that gap in the TypeScript gotcha that silently breaks production. Validate at the boundary, then trust the types inside.

When is plain JavaScript the right call?

Plain JavaScript is the right call when nothing else imports the code. The script that adds anchor links and JSON-LD to my blog posts is 116 lines of Node. One author, one caller, one job. A tsconfig.json would be the second largest file in the directory. It runs, and it's done.

The plain-JS actor is the same shape at a larger size: a scraper with a single output schema, 22 files, JSDoc comments for the next reader, no checker. It has been fine. The honest caveat is that "fine" holds only until a second consumer shows up. The day another actor imports its normaliser, it gets a tsconfig, because at that moment the shape crosses a boundary.

The signal is not line count. It is the number of things that can be wrong about a shape without you noticing.

What about the middle: JSDoc and @ts-check?

There's a third option most comparisons skip. The TypeScript compiler will check a .js file if you ask, with no rename and no build step:

// @ts-check

      /**
       * @param {Record<string, number>} hourly
       * @param {number} hour
       * @param {number} seconds
       */
      export function addSeconds(hourly, hour, seconds) {
        hourly[hour] = (hourly[hour] ?? 0) + seconds;
      }
      

Same checker, same strict flags through checkJs in tsconfig, and the file stays JavaScript. Svelte's core team moved the compiler's internals to this style in 2023 because the tooling friction of a non-standard language wasn't worth it for library code. The team has debated revisiting that call since, but hasn't reversed it. The public API still ships .d.ts types. It's the right answer for glue code that outgrew "just run it" but has not earned a build. It also suits library authors who want the debugger to show the file they wrote.

The decision rule for 2026

Decision tree: will anything import it, does a shape cross a boundary, will it outlive the quarter

  1. Will anything else import it? No: plain JavaScript. Run it and move on.
  2. Does a data shape cross a boundary? Yes: TypeScript with strict and noUncheckedIndexedAccess, plus runtime validation at the edge.
  3. Neither, but it will outlive the quarter? Then TypeScript anyway, because it will grow a boundary. Otherwise JSDoc with // @ts-check until it does.

The question was never "TypeScript or JavaScript". It was "how much checking does this code earn". In 2026 the checking is free at runtime and cheap at build time, so the only honest reason to skip it is that there's nothing for it to catch.

If you want one thing to do today: open a repo you own and find the first untyped boundary, an API response, a message handler, a config read. Give it a type, validate the input at the edge, and turn on noUncheckedIndexedAccess for that package. That one boundary will tell you whether the rest of the repo has earned it.


I write these from real work at astraedus.dev, where I build apps and tools. Building something, or stuck on something like this? Reach me at astraedus.dev or [email protected].

Get the next one in your inbox → subscribe at astraedus.dev.