Run TypeScript locally
Pin your project's Node version with mise and run TypeScript files directly with the right tool for each job, native node, tsx, or tsc.
A new contributor clones the repo on a Monday, types node seed.ts, and watches Node respond with Unknown file extension ".ts". Their machine is on system Node 22, but the project assumes Node 24, where running .ts files directly became a built-in feature. The fix isn’t a README sentence asking everyone to please upgrade their Node, because people rarely read those sentences. The fix is a one-line file the repo commits, so the runtime stops being a property of each developer’s machine. The same gap shows up at the other end of the pipeline. A feature lands locally on the author’s Node 24, passes CI on whichever Node version the GitHub runner happens to default to that day, and ships to a production box on a third minor version. The bug that surfaces three weeks later is “works on my machine” with extra steps.
This lesson also answers a second question, the one that comes up the moment you run a .ts file outside a browser playground: how does this file actually execute on disk? In 2026, three paths exist. Native node strips the type annotations and runs the code with no flag. tsx reads tsconfig.json and transforms what native Node can’t. tsc doesn’t run code at all; it type-checks it, or compiles it to .js when you’re publishing a library. Each path has a named trigger, so you decide by checking the trigger first and then reaching for the matching tool. By the end of this lesson, you’ll have Node 24 pinned in the repo, the three-path decision tree in hand, and a single .ts file you’ve run all three ways on your own machine.
Pin the runtime with mise
Section titled “Pin the runtime with mise”The runtime your code expects is a property of the repo, not of the machine the code happens to land on. This is the same idea the previous lesson applied to the editor: .vscode/settings.json ships in the repo so every teammate’s editor behaves the same way. Here the file is .mise.toml, and the property it pins is Node’s version.
The 2026 default for runtime pinning is mise (formerly rtx). Three things earned it that spot. It’s written in Rust, so it starts in milliseconds, where nvm adds a second or more of shell-script warmup to every prompt. It’s polyglot, so the same tool pins Python, Ruby, Go, and the Trigger.dev runtime the course reaches for later. And it reads .tool-versions and .nvmrc files for free, so a team on an older pinning tool can migrate by dropping .mise.toml next to what they already have. If you or your team is already invested in nvm or Volta and the switching cost outweighs the gain, those still work; this course commits to mise from here on.
The pinned version is Node 24 LTS. Node 24 entered Active LTS in October 2025 and stays Active through October 2026, then drops into Maintenance through April 2028. Production SaaS doesn’t ride the bleeding edge unless a feature is load-bearing. LTS is the default here for the same reason the rest of this stack is: predictable updates and no surprise breakages. Choosing LTS over Current used to mean missing recent language features, but in 2026 that cost is gone. Native TypeScript stripping (the next section’s topic) is available everywhere in Node 24, and the ES2025 features from earlier lessons in this chapter had all landed before Node 24 went LTS.
The install procedure is four steps. The second one, shell activation, is the step new mise users most often skip, and skipping it is why a fresh install can appear to do nothing.
-
Install mise. One command per platform:
- macOS:
brew install mise - Linux:
curl https://mise.run | sh - Windows: install WSL2 first, then follow the Linux path. The course doesn’t run on native Windows shells.
- macOS:
-
Activate mise in your shell. This is the step people skip. Without it,
mise installworks butnodestill resolves to whatever was on yourPATHbefore, and you spend the next thirty minutes wondering why nothing changed. Add the activation line to your shell’s rc file:~/.zshrc eval "$(mise activate zsh)"If you’re on bash, swap
zshforbashin both places. Then reload the shell: close and reopen the terminal, or runexec zsh(orexec bash) in the current one. -
Pin Node 24 at the repo root. From inside the project directory:
Terminal window mise use --pin node@24The
--pinflag writes the full version string (the major plus the latest matching patch) rather than the loose24, so two developers runningmise installa week apart get bit-identical Node binaries. The command creates.mise.tomlin the current directory:.mise.toml [tools]node = "24.4.1"Your exact patch number will differ depending on when you run the command. Commit this file, because it’s how every other developer (and CI, and production) gets the same runtime as you.
-
Install the pinned version and verify. mise downloads Node into its own store (separate from any system Node) and activates it for this directory:
Terminal window mise installmise currentmise currentprints the active version for the directory you’re in, such asnode 24.4.1, or whatever you pinned. From now on, opening a terminal in this folder gives you Node 24 automatically, and opening one anywhere else leaves your other projects untouched.
The runtime is now locked. Every contributor, every CI run, and every production deploy runs against the same Node 24, and the day you bump to Node 26 LTS, you change one file and everyone follows.
The three paths to execute a .ts file
Section titled “The three paths to execute a .ts file”In 2026, three tools take a .ts file on disk and run it. The goal isn’t to pick a favorite, but to match the file to one of three triggers. Native node is the default, and the other two earn their place once the file crosses a named threshold the default can’t handle.
%%{init: {'themeCSS': '.node.term .nodeLabel { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }'} }%%
flowchart LR
start([Run a .ts file])
q1{"Needs path aliases,<br/>JSX, decorators,<br/>enums, or namespaces?"}
q2{"Publishing<br/>a library?"}
q3{"Type-checking<br/>only?"}
tsx["<b>tsx</b><br/>dev only"]
tscEmit["<b>tsc</b><br/>emits .js"]
tscCheck["<b>tsc --noEmit</b>"]
node["<b>node file.ts</b>"]
start --> q1
q1 -- Yes --> tsx
q1 -- No --> q2
q2 -- Yes --> tscEmit
q2 -- No --> q3
q3 -- Yes --> tscCheck
q3 -- No --> node
class tsx,tscEmit,tscCheck,node term
classDef term fill:#1f2937,stroke:#94a3b8,color:#f8fafc The diagram puts the whole decision on one screen. The three subsections that follow take each branch in turn, with its trigger, its mechanics, and its one-line invocation.
Native node: the default
Section titled “Native node: the default”Node 24 reads .ts files natively. At parse time, Node’s internal type-stripper blanks out the parts of the source that are type-only, such as annotations, interfaces, and type aliases, and executes the resulting JavaScript. No flag, no plugin, no build step.
What the type-stripper doesn’t do is the whole reason the other two paths exist:
- It doesn’t read
tsconfig.json. A path alias like@/lib/greetwon’t resolve, because as far as Node knows,tsconfig.jsonis a file with no special meaning. Imports must be relative paths (./lib/greet.ts) or bare specifiers fromnode_modules. - It doesn’t transform. JSX, decorators,
enum, andnamespaceare TypeScript constructs that compile down to new runtime code. They can’t be erased the way a type annotation can; they have to be transpiled, and native Node refuses them. - It doesn’t type-check. A file with type errors runs perfectly fine, because the stripper doesn’t care whether your
numberwas assigned astring. Type-checking istsc --noEmit’s job, covered two subsections down.
Native node is the reach for plain .ts files with relative imports and no code-generation syntax: seed scripts, one-off CLIs, throwaway calculations, anything you’d otherwise write as a .js file. You invoke it the same way as any other Node script:
node hello.tsIf you ever need to disable the behavior, say to debug whether the stripper is the problem, node --no-strip-types hello.ts opts out. The course never needs it.
tsx: when a trigger fires
Section titled “tsx: when a trigger fires”tsx is a third-party CLI that runs .ts files the way node does, with one important difference: it reads tsconfig.json and uses esbuild under the hood to transform what native Node can’t. You reach for it when any one of five conditions holds:
- Imports use path aliases declared in
tsconfig.json(@/lib/...,~/components/...). - The file contains JSX (a standalone React component example outside Next.js, or an iterator over JSX elements).
- The file uses decorators (rare in 2026 SaaS code, but legacy frameworks still use them).
- The file uses
enum. The course never writes them, sinceas constobjects and string-literal unions cover every case, but you’ll meet them in older codebases. - The file uses
namespace, with the same caveat: you only need to recognize it.
Check the trigger before the tool. If none of those five hold, native node is still the right reach; the moment one does, swap in tsx.
Installation lands properly in a later chapter, alongside pnpm and package.json. For this lesson, the one-shot form is pnpm dlx:
pnpm dlx tsx hello.tspnpm dlx downloads tsx into a temporary store, runs it once, and forgets about it, the same pattern npx follows. Once the project has a package.json and tsx is installed as a devDependency, the invocation shortens to pnpm tsx hello.ts.
You’ll also see ts-node in older docs and Stack Overflow answers. It was the dominant runner for years and has since been deprioritized: tsx replaced it for development, and native Node replaced it for plain scripts. If a doc tells you to reach for ts-node, the doc is stale; reach for tsx.
tsc: for type-checking and library publish
Section titled “tsc: for type-checking and library publish”tsc, the TypeScript compiler, is the only one of the three tools that doesn’t run your code by default. It does two other things, each with its own trigger.
The first trigger is type-checking the codebase. tsc --noEmit runs the full type-checker across every file tsconfig.json includes and emits no .js. It’s the dedicated type-check, the same one your editor runs in the background. From the project chapters onward, CI gates every PR on it, so a type error blocks the merge.
pnpm dlx tsc --noEmit(pnpm dlx tsc works the same way pnpm dlx tsx did in the previous section: one-shot, no install. Once the project has a package.json, the form is pnpm tsc --noEmit.)
The second trigger is publishing a library to npm. tsc without --noEmit reads your .ts source, type-checks it, and writes out the corresponding .js plus .d.ts type declarations, which is what publishable packages distribute. This course builds an application, not a library, so the emit path is named here only so you recognize it.
Neither of those is “running a .ts file.” If your goal is to execute the code, tsc is the wrong tool; reach for node or tsx. If your goal is to verify the types, node and tsx are the wrong tools, because they don’t type-check at all.
A worked example: write one .ts file, run it three ways
Section titled “A worked example: write one .ts file, run it three ways”The fastest way to make the three paths stick is to run them on your own machine. You’ll write one file and run it with node to watch it work, add a path alias and watch node fail in the way the previous section described, switch to tsx and watch it pass, then run tsc --noEmit and watch a silent type-check confirm the file is sound. That’s cause and effect, three times against the same source.
-
Create
hello.tsat the repo root. A typedgreetfunction and a singleconsole.log:hello.ts const greet = (name: string): string => `Hello, ${name}`;console.log(greet('world')); -
Run it with native node. Inside the same directory:
Terminal window node hello.tsOutput:
Hello, worldThe type annotations were stripped and the JavaScript ran, no flag needed. This is the daily reach for plain
.tsscripts. -
Add a path alias. Make a
src/lib/directory, move thegreetfunction into its own module, and import it fromhello.tsvia the@/...alias instead of a relative path. The new file:src/lib/greet.ts export const greet = (name: string): string => `Hello, ${name}`;And the updated
hello.ts:hello.ts import { greet } from '@/lib/greet';console.log(greet('world'));The project layout now looks like this:
- hello.ts imports from
@/lib/greet Directorysrc/
Directorylib/
- greet.ts
- hello.ts imports from
-
Re-run with native node and watch it fail. Same command as before:
Terminal window node hello.tsOutput (trimmed):
node:internal/modules/package_json_reader:316throw new ERR_MODULE_NOT_FOUND(packageName, fileURLToPath(base), null);^Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@/lib' imported from /path/to/hello.tsThis is the failure that defines the path-alias trigger from the previous section. Native Node doesn’t read
tsconfig.json, so the@/lib/greetspecifier looks to it like a scoped package name. Node parses@/libas the package (the@-prefixed scope plus the first path segment), hunts for it innode_modules, finds nothing, and throws. The fix isn’t to switch back to relative imports; the fix is to switch tools. -
Add a minimal
tsconfig.jsonand switch to tsx. The path alias has to be declared somewhere. Createtsconfig.jsonat the repo root with just enough to teachtsxhow to resolve@/...:tsconfig.json {"compilerOptions": {"module": "preserve","moduleResolution": "bundler","baseUrl": ".","paths": {"@/*": ["src/*"]}}}moduleResolution: "bundler"is the canonical fit for tools liketsxandesbuild: it understands extensionless imports and resolvespathsthe way the runtime does. The full file, with strictness flags, target, and lib settings, lands in a later chapter alongsidepackage.json. This minimal version is enough to make the alias resolve. Now run withtsx:Terminal window pnpm dlx tsx hello.tsOutput:
Hello, worldtsxreadtsconfig.json, resolved@/lib/greettosrc/lib/greet.ts, and ran the file. Same source code and same output as step 2; the only difference is which tool understood the path alias. -
Type-check with tsc —noEmit. No code execution, just the type-checker:
Terminal window pnpm dlx tsc --noEmitOutput:
Nothing. Silent success is the convention, since
tsconly speaks up when it finds an error. To confirm the type-checker is actually working, edithello.tsto callgreet(42)instead ofgreet('world')and rerun. You’ll get a clearArgument of type 'number' is not assignable to parameter of type 'string'along with the file and line. Change it back to maketsc --noEmitsilent again.
You’ve now run the same file through all three paths, and the decision tree from earlier should feel concrete. Native node was the default until the path-alias trigger fired and flipped it to tsx, and tsc --noEmit answered a different question (does it type-check?) that neither runner can answer. Three tools, three triggers, one file.
Check yourself: pick the path
Section titled “Check yourself: pick the path”Match each of the five scenarios to the execution path you’d reach for. The right column repeats some entries on purpose, because multiple triggers route to the same tool. That’s the whole point: check the trigger first, then pick the tool.
Match each scenario to the execution path you'd reach for. Click an item on the left, then its match on the right. Press Check when done.
node script.ts@/lib/...pnpm tsx script.tspnpm tsc --noEmitpnpm tsx script.ts (JSX trigger)pnpm tsc (emits .js)External resources
Section titled “External resources”Four references worth bookmarking. The first three are the canonical sources for the three tools you just installed and used, and the fourth lets you verify the Node LTS dates yourself.
The official reference for native type-stripping, the syntax it supports, and the syntax it refuses.
Install, shell activation, and the mise use command — the canonical source if anything in the install procedure trips.
The README covers watch mode, ESM and CJS interop, and the full list of TypeScript features tsx transforms that native Node refuses.
The official table of which Node majors are in Active LTS, Maintenance, or end-of-life, with the exact dates.