Starting with Node 22.18.0 it is possible to run TypeScript 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:
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:
{
"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:
- 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. - Create a separate
tsconfig.jsoninside the folder where the scripts are located and run a secondtsc --project scripts/tsconfig.json --noEmit. By using the$extendsoption from TypeScript you can still keep most of your configuration centralized. - Don't explicitly check for erasable syntax.
CommonJS vs ESM
Already since version 12 NodeJS has had support for ESM - 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.
// 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, 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?).
How NodeJS decides if a file is to be interpreted as ESM or as CommonJS is well-documented. 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:
{
"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:
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:
{
"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:
{
"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:
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:
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:
- Use a bundler to build your production artifact as the bundler will take care of this translation.
- Do not mix production code and native TypeScript code.
- 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:
{
"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:
{
"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:
{
"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.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.