Starting with Node 22.18.0 it is possible to run [TypeScript natively](https://nodejs.org/en/learn/typescript/run-natively) from the Node interpreter. This feels like the best of both worlds - the benefits of type inference in your IDE without need to first transpile. I have been using this feature for the small scripts that I use during development and during inspections. Think of scripts to generate or validate a keypair, or a script to build a configuration file for local development. Needing to run `tsc` is a bit of a nuisance for these scripts, and therefore solutions like `ts-node` and `tsx` were introduced. But now this is built-in in Node, and that means that we have one dependency less in our `package.json`.
But before you start using this in your codebases there are a few caveats that you should probably be aware of.
## Type safety
First a word of caution; Node does not actually perform the type checks that TypeScript performs. It merely _strips_ the type information from the TypeScript code. This does mean an added, although barely noticable, latency to the execution of your scripts. More importantly it means that the type safety is **not guaranteed**. This is actually the same thing that happens with bundlers like `esbuild`. That's why you should always also run `tsc --noEmit` on your TypeScript code when you use a bundler or when running it natively with Node.
## Restricted syntax
But more importantly it means that you cannot use any syntax that is TypeScript only. Two example of this that I personally ran into were enums and parameter properties:
```typescript
export enum MyEnum {
OptionA = 'option-a',
OptionB = 'option-b',
};
export class MyClass {
public constructor(
public readonly name: string,
private readonly value: number,
) {
}
}
```
There is a way to let TypeScript block this via the `tsconfig.json`:
```json
{
"compilerOptions": {
"erasableSyntaxOnly": true
}
}
```
Keep in mind that - unlike `package.json` - `tsconfig.json` files are **not** hierarchical read by `tsc`. I personally tend to use the native TypeScript feature for specific scripts, which are located outside the TypeScript files meant for production. Contrary to linters like `eslint`, `oxlint` and `biome` TypeScript does not have a way to specify compiler options per folder. So you have three options if you want to use this option:
1. Enable this flag in your root `tsconfig.json`, with the side effect that you are limited in your TypeScript syntax. But this approach will give you the option to validate the typing of your scripts.
2. Create a separate `tsconfig.json` inside the folder where the scripts are located and run a second `tsc --project scripts/tsconfig.json --noEmit`. By using the [`$extends`](https://www.typescriptlang.org/tsconfig/#extends) option from TypeScript you can still keep most of your configuration centralized.
3. Don't explicitly check for erasable syntax.
## CommonJS vs ESM
Already since version 12 NodeJS has had support for [ESM](https://nodejs.org/api/esm.html) - ECMAScript modules -, while also keeping support for CommonJS modules. We are now at version 25 of NodeJS and still there are libraries that are only available as CommonJS modules, and some libraries have made the step towards ESM. But if you want to use native TypeScript this becomes an even more concious decision, because we cannot rely on `tsc` to convert ESM syntax to CommonJS syntax - or vise versa - based on the compiler options in `tsconfig.json`.
```typescript
// CommonJS way of including files and modules
const { method } = require('./method');
const module = require('module');
// ESM way of including files and modules
import { method } from './method.js';
import module from 'module';
```
ESM has some advantages over CommonJS, such as a better capability of [tree shaking](https://developer.mozilla.org/en-US/docs/Glossary/Tree_shaking), but it also has a downside; you will need to specify the extension of the file for relative imports. If you're using `tsc` to build your production code you will end up with multiple `.js` files, and therefore you will need to explicitly use the `.js` extension in your relative imports. Even in your TypeScript code, because `tsc` does not rewrite those imports ([yet](https://github.com/microsoft/TypeScript/issues/61021)?).
How NodeJS decides if a file is to be interpreted as ESM or as CommonJS is [well-documented](https://nodejs.org/api/packages.html#determining-module-system). In a nutshell; It checks the extension, then the nearest `package.json` with a `type` property, then the `--input-type` flag, and finally it guesses.
If you use a bundler then this usually is corrected by the bundler, but this is not the case for native TypeScript. So, you better make an explicit choice and go for one or the other.
**NB** This really only is an issue if you want to use code from another file in your codebase. If you only use modules from NodeJS itself then this is simple choice of syntax, and I would recommend to steer the module resolution by using either the extension `.mts` or `.cts`. When importing from an (P)NPM package you are dependent of the package maintainers as they how they offer their library; as ESM, as CJS or as both.
### ESM
The moment you want to introduce relative imports into your script I would recommend using ESM. Not only is it more future-proof, and the syntax is used in the code examples on the TypeScript website, but I have yet to find a nice way to make relative imports work using CommonJS. Since the files that are used via native TypeScript in my repositories are typically grouped in a single folder I tend to add a minimal `package.json` to that folder to indicate ESM. Of course, this is only relevant if your root `package.json` explicitly indicates CommonJS usage:
```json
{
"type": "module"
}
```
Now I can split my code in file - just like I would do for production code -, and for relative imports I add the explicit extension `.ts`:
```typescript
import { method } from './method.ts';
method();
```
There is one small extra step to take for this to work properly; we need to tell TypeScript that we're going to use these extensions:
```json
{
"compilerOptions": {
"allowImportingTsExtensions": true
}
}
```
Using a linter like `eslint`, `oxlint` or `biome` you can enforce usage of extensions in imports, or not, per folder.
### CommonJS
If you decide to use CommonJS as module resolution you can do so via `package.json`:
```json
{
"module": "commonjs"
}
```
There is a big caveat for this however; you will still need to include relative paths with an extension when using native TypeScript. Where this will work in plain JavaScript:
```javascript
const { sum } = require('./sum');
sum(1, 2);
```
This will not work when using native TypeScript. In that situation you are forced to specify the extension:
```typescript
const { sum } = require('./sum.ts');
sum(1, 2);
```
### Mixing "script" files and "production" files
So you've built a nice script, and you really want to use a class, method or type definition that is also used in the code that is used in your "production" code. This _could_ become problematic if you don't use a bundler or postprocessor. Since you need to specify the `.ts` - or `.mts` or `.cts` - extension on your imports, you will also need to do this on your "production" code. This can become a problem, because TypeScript will not rewrite the extension for you. And that means that the transpiled code will have the extension `.js`, but files will try to include it using the extension `.ts`.
There are three workarounds for this:
1. Use a bundler to build your production artifact as the bundler will take care of this translation.
2. Do not mix production code and native TypeScript code.
3. Use a postprocessor to correct the imports after TypeScript transpilation.
## My setup
This may sound like a lot of caveats and a lot of stuff to deal with, just so you don't have to introduce a dependency on `ts-node`/`tsx` or add a `tsc` step to your workflow. And you might be right. But I have found that with a "modern" codebase it is just a handful of configurations to make native TypeScript possible.
It is 2026, so I opt for ESM for module resolution. I think it is the way forward. This means that my `./package.json` configures this for the entire codebase:
```json
{
"type": "module"
}
```
This allows me to use the `import from` and `export` syntax, and to use `.ts` extensions on all my TypeScript files. I try to avoid mixing native TypeScript code and production code by separating them in my filesystem. Typically, the native TypeScript code is for development and/or CI/CD only, and therefore I tend to keep it in a separate `scripts` folder.
Because size always matters I use a bundler like `esbuild` to bundle, minify and tree shake my code. This will minimize my code artifact, and that _can_ mean better performance. Especially on serverless architectures a smaller artifact can lead to much lower cold start times. This also makes that I can configure my linter to always require relative imports to have a `.ts` extension. I am currently using Biome for linting:
```json
{
"linter": {
"rules": {
"correctness": {
"useImportExtensions": "error"
}
}
}
}
```
Lastly I configure TypeScript to allow `.ts` extensions in my imports and - because I use a bundler - I explicitly disable emitting of transpiled code:
```json
{
"compilerOptions": {
"noEmit": true,
"allowImportingTsExtensions": true
}
}
```
I don't have any additional safeguards in place to prevent TypeScript-only syntax in the code that is run via native TypeScript. These scripts are either used for local development or as an inspection step in a CI pipeline, so if they fail the fallout is not noticable for my production environment.
## tl/dr;
Since NodeJS version 22 it is possible to have NodeJS run TypeScript code without the need to transpile. This feature is called _native TypeScript_. It adds a minimal latency and should therefore probably not be used for production purposes, but it allows typing and typesafety in your scripts. When you create larger scripts, and you want to perform relative imports then you are best off switching your entire codebase to ESM due to the requirement of extensions in your relative imports.