node --import "data:text/javascript,import {register} from 'node:module'; import {pathToFileURL} from 'node:url'; register('ts-node/esm', pathToFileURL('./'))" my-script.ts
It works on Node.js v20.8.0+. 😢
...
You can create a file named ts-loader.js:
import {register} from 'node:module'
import {pathToFileURL} from 'node:url'
register('ts-node/esm', pathToFileURL('./'))
And then:
node --import ./ts-loader.js my-script.ts
Remember: Don't write the loader path as ts-loader.js (if loader file is located in your source). But make sure write it with relative path: ./ts-loader.js as you do it in your internal imports!
Cons:
- It can't resolve w/o extension
imports inside my-script.ts (and recursively inside files that are imprted in my-script.ts)! So all your imports should have explicit .js extension (or possible .ts extension if you configure allowImportingTsExtensions in your tsconfig.json).
- Also, you can't omit
/index.js//index.ts from the end of your paths!
- It doesn't support
jsx/tsx contents!
- As I checked, it doesn't respect your
compilerOptions.paths configuration in your tsconfig.json. For example, if you configured something like "@/*": ["./src/*"] in your paths then the above loader can't resolve @/... prefixed imports inside my-script.ts (and inside files imprted directly or inderectly in it)!
- In very simple cases that I tested, it was about 2x slower than the below perfect solution using esbuild:
Alternative perfect solution using register()/--import of node.js v20.8+ and esbuild:
I wrote this loader-script in my projects:
register-ts-loader.js:
// Useful links:
// https://stackoverflow.com/a/68621282/5318303
// https://github.com/nodejs/loaders-test/blob/main/typescript-loader/loader.js
// https://github.com/nodejs/loaders-test/blob/main/commonjs-extension-resolution-loader/loader.js
import {transform} from 'esbuild'
import {readFile} from 'node:fs/promises'
import {isBuiltin, register} from 'node:module'
import {dirname, extname, posix, relative, resolve as resolvePath, sep} from 'node:path'
import {fileURLToPath, pathToFileURL} from 'node:url'
// 💡 It's probably a good idea to put this loader near the end of the chain.
register(import.meta.url, pathToFileURL('./')) // Register this file, itself.
// noinspection JSUnusedGlobalSymbols
export async function resolve(specifier, context, nextResolve) {
if (isBuiltin(specifier)) return nextResolve(specifier, context)
const defaultResolutionTry = nextResolve(specifier, context)
const {data: defaultResolutionResult} = await defaultResolutionTry.then(
(data) => ({data}),
(error) => ({error}), // ERR_MODULE_NOT_FOUND | ERR_UNSUPPORTED_DIR_IMPORT | ... (?)
)
// If module found and its format successfully detected use: `defaultResolutionResult`
if (defaultResolutionResult && !('format' in defaultResolutionResult && !defaultResolutionResult.format))
return defaultResolutionResult
if (specifier.startsWith('@/')) {
specifier =
'./' +
posix.join(
toPosix(relative(dirname(fileURLToPath(context.parentURL)), resolvePath('src'))), // TODO: Do this according to project's "tsconfig.json"
posix.relative('@', specifier),
)
// Retry default-resolution with new resolved `specifier`:
const defaultResolutionResult = await nextResolve(specifier, context).catch(() => {})
// If module found and its format successfully detected use: `defaultResolutionResult`
if (defaultResolutionResult && !('format' in defaultResolutionResult && !defaultResolutionResult.format))
return defaultResolutionResult
}
const originalExt = extname(specifier)
let resolvedExt = ''
// Let's try to resolve the module:
const generalTries = () =>
nextResolve(specifier + (resolvedExt = '.js'), context)
.catch(() => nextResolve(specifier + (resolvedExt = '.jsx'), context))
.catch(() => nextResolve(specifier + (resolvedExt = '.ts'), context))
.catch(() => nextResolve(specifier + (resolvedExt = '.tsx'), context))
.catch(() => nextResolve(specifier + '/index' + (resolvedExt = '.js'), context))
.catch(() => nextResolve(specifier + '/index' + (resolvedExt = '.jsx'), context))
.catch(() => nextResolve(specifier + '/index' + (resolvedExt = '.ts'), context))
.catch(() => nextResolve(specifier + '/index' + (resolvedExt = '.tsx'), context))
.catch(() => defaultResolutionTry) // If our tries to resolve the module failed, return `defaultResolutionTry` promise.
const nextResolverResult = ['.js', '.jsx'].includes(originalExt)
? // 1. Special case: First replace ".jsx?" extension with ".tsx?":
await nextResolve(
specifier.slice(0, -originalExt.length) + // Cut ".jsx?"
(resolvedExt = originalExt.replace('j', 't')), // Append ".tsx?"
context,
).catch(generalTries) // If the above special-try failed, continue to `generalTries` ...
: await generalTries() // 2. General cases: Just do `generalTries` ...
return EXTENSIONS.has(resolvedExt)
? {
format: resolvedExt, // Provide a signal to `load()`
shortCircuit: true,
url: nextResolverResult.url,
}
: nextResolverResult
}
// noinspection JSUnusedGlobalSymbols
export async function load(url, context, nextLoad) {
if (context.format === 'json')
return {
format: 'module',
shortCircuit: true,
source: `export default ${await readFile(fileURLToPath(url), 'utf-8')}`,
}
const nextLoaderResult = await nextLoad(url, {format: 'module', ...context})
if (!EXTENSIONS.has(context.format)) return nextLoaderResult
const rawSource = nextLoaderResult.source.toString()
const {code: transpiledSource} = await transform(rawSource, {
loader: 'tsx', // https://esbuild.github.io/content-types/#typescript + https://esbuild.github.io/content-types/#jsx
sourcemap: 'inline',
target: 'esnext',
})
return {
format: 'module',
shortCircuit: true,
source: transpiledSource,
}
}
const EXTENSIONS = new Set(['.jsx', '.tsx', '.ts'])
/** Convert **relative-path** to posix format. */
const toPosix = (relativePath) => relativePath.replaceAll(sep, posix.sep)
And then:
node --import ./register-ts-loader.js my-script.ts
without the above cons.😊
Don't forget npm i -D esbuild.
Also, see: https://esbuild.github.io/api/#transform.
See my other answer about new Node.js features around --loader and newer --import + register():
UPDATE:
tsx package is another perfect solution, based on esbuild and some new features of Node.js. Including other useful features, like TypeScript REPL, shebang, etc.!
Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import when attempting to start Nodejs App locally
Alternatively, you can use Bun.js and enjoy its out of the box TypeScript support and its other features and optimizations.