Run TypeScript locally
Pin your project's Node version with mise, then run TypeScript files with the right tool for the job: native node, tsx, or tsc.
A new contributor clones the repo, types node seed.ts, and gets Unknown file extension ".ts".
Their machine runs Node 22, but the project assumes Node 24, where running .ts files directly became built in.
A README sentence asking everyone to upgrade won’t hold; a file the repo commits will.
The same version gap runs through CI and production, where it surfaces weeks later as the bug that “works on my machine.”
This lesson closes that gap and answers the question that comes up the first time you run a .ts file: how does it actually execute?
You’ll pin Node 24 in the repo, then run one .ts file three ways: native node, tsx, and tsc.
By the end you’ll have a decision tree for choosing among them.
Pin the Node version with mise
Section titled “Pin the Node version with mise”The runtime your code expects belongs to the repo, not the machine, just like the editor settings from the previous lesson.
The committed file is .mise.toml, and what it pins is Node’s version.
The tool is mise (formerly rtx).
It’s written in Rust, so it starts in milliseconds where nvm adds a second to every shell prompt, and it’s polyglot, so one tool pins Python, Go, and other runtimes alongside Node.
This course uses mise from here on.
The pinned version is Node 24, which entered LTS in October 2025 and is supported through April 2028. LTS costs you nothing here: native TypeScript stripping (the next section’s topic) and this chapter’s ES2025 features all ship in Node 24.
The install is four steps. Step two, shell activation, is the one new users skip, which 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. Skip this and
mise installruns, butnodestill resolves to whatever was on yourPATHbefore. Add the activation line to your shell’s rc file:~/.zshrc eval "$(mise activate zsh)"On bash, swap
zshforbashin both places. Then reload the shell: 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 (major plus latest patch) rather than the loose24, so two developers runningmise installa week apart get identical Node binaries. The command writes.mise.tomlin the current directory:.mise.toml [tools]node = "24.4.1"Your patch number will differ depending on when you run the command. Commit this file: it’s how every other developer, CI, and production gets your runtime.
-
Install the pinned version and verify. mise downloads Node into its own store, separate from any system Node:
Terminal window mise installmise currentmise currentprints the active version for the current directory, such asnode 24.4.1. From now on a terminal in this folder gives you Node 24 automatically, and one elsewhere leaves your other projects untouched.
Three ways to run a .ts file
Section titled “Three ways to run a .ts file”Three tools run a .ts file.
Native node is the default; the other two earn their place once a file needs something the default can’t do.
%%{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 Native node: the default
Section titled “Native node: the default”Node 24 reads .ts files natively.
Its type-stripper blanks out the type-only parts of the source, such as annotations, interfaces, and type aliases, then runs the resulting JavaScript.
No flag, no plugin, no build step.
What the stripper doesn’t do is why the other two paths exist:
- It doesn’t read
tsconfig.json. A path alias like@/lib/greetwon’t resolve. Imports must be relative paths (./lib/greet.ts) or bare specifiers fromnode_modules. - It doesn’t transform. JSX, decorators,
enum, andnamespacecompile to new runtime code, so they can’t be erased like a type annotation, and native Node rejects them. - It doesn’t type-check. A file with type errors still runs; verifying types is
tsc --noEmit’s job, two subsections down.
So native node fits plain .ts files with relative imports and no code-generation syntax: seed scripts, one-off CLIs, throwaway calculations, anything you’d otherwise write as .js.
node hello.tstsx: for path aliases and transforms
Section titled “tsx: for path aliases and transforms”tsx is a third-party CLI that runs .ts files like node, but reads tsconfig.json and uses esbuild to transform what native Node can’t.
Reach for it when any one of five conditions holds:
- Imports use path aliases from
tsconfig.json(@/lib/...,~/components/...). - The file contains JSX, like a standalone React component outside Next.js.
- Decorators, rare in 2026 SaaS code but still seen in legacy frameworks.
enum, which this course never writes (as constobjects and string-literal unions cover every case) but you’ll meet in older codebases.namespace, same story: recognize it, don’t write it.
Installation comes in a later chapter, alongside pnpm and package.json.
For now, run the one-shot form:
pnpm dlx tsx hello.tspnpm dlx downloads tsx, runs it once, and forgets it, the same pattern as npx.
Once tsx is installed as a devDependency, this shortens to pnpm tsx hello.ts.
You’ll also meet ts-node in older docs; it has been deprioritized, so reach for tsx instead.
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 that doesn’t run your code.
It does two other things, each with its own trigger.
The first is type-checking the codebase.
tsc --noEmit runs the full type-checker across every file tsconfig.json includes and emits no .js, the same check 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 is one-shot with no install, like pnpm dlx tsx. Once a package.json exists, the form is pnpm tsc --noEmit.)
The second is publishing a library to npm.
tsc without --noEmit type-checks your .ts source and writes out the matching .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 trigger runs a .ts file: to execute code, reach for node or tsx; to verify types, reach for tsc.
Worked example: one file, all three tools
Section titled “Worked example: one file, all three tools”Run all three tools on one file: node works, a path alias breaks it, tsx fixes it, tsc --noEmit type-checks it.
-
Create
hello.tsat the repo root. A typedgreetand oneconsole.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, worldNode stripped the types and ran the JavaScript, no flag needed.
-
Add a path alias. Move
greetintosrc/lib/greet.tsand import it fromhello.tsthrough the@/...alias. 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 layout:
- 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.tsNative Node doesn’t read
tsconfig.json, so it reads@/libas anode_modulespackage and throws when it can’t find one; the fix is a different tool, not relative imports. -
Add a minimal
tsconfig.jsonand switch to tsx. Declare the alias in a minimaltsconfig.jsonat the repo root:tsconfig.json {"compilerOptions": {"module": "preserve","moduleResolution": "bundler","baseUrl": ".","paths": {"@/*": ["src/*"]}}}moduleResolution: "bundler"is the right fit fortsx; the full config comes later. Now run withtsx:Terminal window pnpm dlx tsx hello.tsOutput:
Hello, worldSame source and output as step 2;
tsxjust resolved the alias. -
Type-check with tsc —noEmit. No execution, just the type-checker:
Terminal window pnpm dlx tsc --noEmitOutput:
Nothing:
tscspeaks up only on an error. To check it works, callgreet(42)and rerun; you’ll getArgument of type 'number' is not assignable to parameter of type 'string'with the file and line. Change it back to maketsc --noEmitsilent again.
Match each scenario to a tool
Section titled “Match each scenario to a tool”Some answers repeat, because more than one trigger routes to the same tool.
Match each scenario to the execution path you'd reach for. Check the trigger first, then pick the tool. 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”The official sources for the three tools you installed, plus the Node release schedule.
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.