Skip to content

Instantly share code, notes, and snippets.

@khalidx
Last active November 14, 2024 08:26
Show Gist options
  • Save khalidx/1c670478427cc0691bda00a80208c8cc to your computer and use it in GitHub Desktop.
Save khalidx/1c670478427cc0691bda00a80208c8cc to your computer and use it in GitHub Desktop.
A Node + TypeScript + ts-node + ESM experience that works.

The experience of using Node.JS with TypeScript, ts-node, and ESM is horrible.

There are countless guides of how to integrate them, but none of them seem to work.

Here's what worked for me.

Just add the following files and run npm run dev. You'll be good to go!

package.json

{
  "private": true,
  "type": "module",
  "exports": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "sideEffects": false,
  "files": [
    "./dist/"
  ],
  "engines": {
    "node": "^20.9.0",
    "npm": "^10.1.0"
  },
  "scripts": {
    "dev": "node --no-warnings --enable-source-maps --loader ts-node/esm src/index.ts",
    "dev:watch": "nodemon --watch src/ -e ts --exec \"npm run dev\"",
    "test": "node --no-warnings --enable-source-maps --loader ts-node/esm --test src/**/*.test.ts",
    "test:watch": "node --no-warnings --enable-source-maps --loader ts-node/esm --test --watch src/**/*.test.ts"
  },
  "dependencies": {},
  "devDependencies": {
    "@sindresorhus/tsconfig": "^5.0.0",
    "nodemon": "^3.0.3",
    "ts-node": "^10.9.1",
    "typescript": "^5.2.2"
  }
}

tsconfig.json

{
  "extends": "@sindresorhus/tsconfig",
  "compilerOptions": {
    "outDir": "./dist/",                              /* Specify an output folder for all emitted files. */
    "lib": ["ES2022"],
    "target": "ES2022",
    "declarationMap": true,                           /* Create sourcemaps for d.ts files. */
    "sourceMap": true,                                /* Create source map files for emitted JavaScript files. */
    "importsNotUsedAsValues": "remove",               /* Specify emit/checking behavior for imports that are only used for types. */    
    "isolatedModules": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */
    "esModuleInterop": true
  },
  "include": [
    "./src/**/*.ts"
  ],
  "ts-node": {
    "esm": true,
    "transpileOnly": true,
    "files": true,
    "experimentalResolver": true
  }
}

src/utilities/node.ts

Some utilities for getting similar behavior as __filename, __dirname, and require.main === module in Node.JS CommonJS.

This file is optional.

import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
import { argv } from 'node:process'
import { createRequire } from 'node:module'

/**
 * This is an ESM replacement for `__filename`.
 * 
 * Use it like this: `__filename(import.meta)`.
 */
export const __filename = (meta: ImportMeta): string => fileURLToPath(meta.url)

/**
 * This is an ESM replacement for `__dirname`.
 * 
 * Use it like this: `__dirname(import.meta)`.
 */
export const __dirname = (meta: ImportMeta): string => dirname(__filename(meta))

/**
 * Indicates that the script was run directly.
 * This is an ESM replacement for `require.main === module`.
 * 
 * Use it like this: `isMain(import.meta)`.
 */
export const isMain = (meta: ImportMeta): boolean => {
  if (!meta || !argv[1]) return false
  const require = createRequire(meta.url)
  const scriptPath = require.resolve(argv[1])
  const modulePath = __filename(meta)
  return scriptPath === modulePath
}
@khaosdoctor
Copy link

khaosdoctor commented Nov 21, 2023

Add tsx and use it instead of node. Enjoy.

TSX is amazing, the only problem you get with it (besides the decorators) is that if you want to run your node process with the .env file loading you will eventually lose the ability to watch as you need to pass it to NODE_OPTIONS

NODE_OPTIONS='--import tsx' node --env-file .env script.js

It's not THAT big of a dealbreaker, but it can be annoying.

Also, very important, --loader is deprecated on Node 20(ish?) and will be replaced by --import

@bogeeee
Copy link

bogeeee commented Nov 21, 2023

Does it properly halt on your breakpoints in .ts files ?

@khaosdoctor
Copy link

Does it properly halt on your breakpoints in .ts files ?

It should yes.

Another option is to always ditch Jest and use the native Node.js test runner. You can run tests with TS easier with glob -c "node --import tsx --test --experimental-code-coverage" "some/**/test/folder/*.test.ts"

as far as I know the glob support is coming in node 22

@o-az
Copy link

o-az commented Nov 22, 2023

Add tsx and use it instead of node. Enjoy.

TSX is amazing, the only problem you get with it (besides the decorators) is that if you want to run your node process with the .env file loading you will eventually lose the ability to watch as you need to pass it to NODE_OPTIONS

NODE_OPTIONS='--import tsx' node --env-file .env script.js

It's not THAT big of a dealbreaker, but it can be annoying.

Also, very important, --loader is deprecated on Node 20(ish?) and will be replaced by --import

you could do this and get watch/live reload and env file

node --import tsx --env-file .env --watch index.ts

@khaosdoctor
Copy link

Add tsx and use it instead of node. Enjoy.

TSX is amazing, the only problem you get with it (besides the decorators) is that if you want to run your node process with the .env file loading you will eventually lose the ability to watch as you need to pass it to NODE_OPTIONS

NODE_OPTIONS='--import tsx' node --env-file .env script.js

It's not THAT big of a dealbreaker, but it can be annoying.
Also, very important, --loader is deprecated on Node 20(ish?) and will be replaced by --import

you could do this and get watch/live reload and env file

node --import tsx --env-file .env --watch index.ts

Weird, my test here kept reloading the file with --watch

@sleep-written
Copy link

Now using Typescript with ts-node in ESM projects it's a mess, however sometime ago I made a library called @bleed-believer/path-alias to resolve path aliases (uses ts-node as dependency), and handle the troubles asociated with this development environment. This library it's capable to run ESM projects with Node 20, even if you don't uses path alias in your project.

If you want to try it:

  • Set your project as ESM in ./package.json file:

    {
      "name": "my-project",
      "version": "0.0.0",
      "type": "module"
    }
  • Set your tsconfig.json:

    {
      "compilerOptions": {
        "target": "ES2022",
        "module": "Node16",
        "moduleResolution": "Node16",
    
        /* More configuration options... here */
    }
  • Install the package:

npm i --save @bleed-believer/path-alias
  • Finally run your project:
node --import @bleed-believer/path-alias ./src/index.ts

@o-az
Copy link

o-az commented Nov 25, 2023

Add tsx and use it instead of node. Enjoy.

TSX is amazing, the only problem you get with it (besides the decorators) is that if you want to run your node process with the .env file loading you will eventually lose the ability to watch as you need to pass it to NODE_OPTIONS

NODE_OPTIONS='--import tsx' node --env-file .env script.js

It's not THAT big of a dealbreaker, but it can be annoying.
Also, very important, --loader is deprecated on Node 20(ish?) and will be replaced by --import

you could do this and get watch/live reload and env file

node --import tsx --env-file .env --watch index.ts

Weird, my test here kept reloading the file with --watch

If you want the file/s to not reload you could try tsup build with --onSuccess:

https://github.com/o-az/typescript-template/blob/d60b0ddffdfc34b5412260b30ec6cf9511eece9c/package.json#L26

@malixsys
Copy link

Would love to see this updated with live code reloading and debugging.

Use vavite…

https://github.com/cyco130/vavite

@habtamu
Copy link

habtamu commented Nov 26, 2023

Do we have a fix to resolve this issue?

➜ node-typescript-esm npm run dev

dev
node --no-warnings --enable-source-maps --loader ts-node/esm src/index.ts

node:internal/process/esm_loader:40
internalBinding('errors').triggerUncaughtException(
^
[Object: null prototype] {
[Symbol(nodejs.util.inspect.custom)]: [Function: [nodejs.util.inspect.custom]]
}

Node.js v20.9.0

@Aicirou
Copy link

Aicirou commented Dec 6, 2023

Do we have a fix to resolve this issue?

instead of

"node --no-warnings --enable-source-maps --loader ts-node/esm src/index.ts"

try tsx,

"node --watch --no-warnings --enable-source-maps --import tsx ./src/index.ts"

@a0viedo
Copy link

a0viedo commented Dec 11, 2023

just wrote about my experience setting up ESM, TypeScript and Node.js v20 here. I ended up using SWC for transpilation, tsc for type-checking and tsx for running TypeScript files

@bogeeee
Copy link

bogeeee commented Jan 30, 2024

Sure, that --enable-source-maps is needed ? It outputs stacks and debugs fine (with Webstorm) without that flag, just with node --import tsx

@c0bra
Copy link

c0bra commented Feb 8, 2024

Do we have a fix to resolve this issue?

instead of

"node --no-warnings --enable-source-maps --loader ts-node/esm src/index.ts"

try tsx,

"node --watch --no-warnings --enable-source-maps --import tsx ./src/index.ts"

When I try tsx, I can run from the cli, but inside of a Docker container, I get TypeError [Error]: undefined is not iterable (cannot read property Symbol(Symbol.iterator)) at some place in the internals of tsx. Nothing in their github issues that seems to correspond.

@jiayixu420
Copy link

Hi, everyone! I hope you're doing well.
I ran the ts file using ts-node but got the error below.

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for E:\work\test\scan.ts
    at new NodeError (node:internal/errors:406:5)
    at Object.getFileProtocolModuleFormat [as file:] (node:internal/modules/esm/get_format:99:9)
    at defaultGetFormat (node:internal/modules/esm/get_format:142:36)
    at defaultLoad (node:internal/modules/esm/load:120:20)
    at ModuleLoader.load (node:internal/modules/esm/loader:396:13)
    at ModuleLoader.moduleProvider (node:internal/modules/esm/loader:278:56)
    at new ModuleJob (node:internal/modules/esm/module_job:65:26)
    at ModuleLoader.#createModuleJob (node:internal/modules/esm/loader:290:17)
    at ModuleLoader.getJobFromResolveResult (node:internal/modules/esm/loader:248:34)
    at ModuleLoader.getModuleJob (node:internal/modules/esm/loader:229:17) {
  code: 'ERR_UNKNOWN_FILE_EXTENSION'
}

The tsconfig.json file looks like this:

{
    "compilerOptions": {
        /* Visit https://aka.ms/tsconfig.json to read more about this file */
        /* Basic Options */
        // "incremental": true,                   /* Enable incremental compilation */
        "target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */,
        "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
        // "lib": [],                             /* Specify library files to be included in the compilation. */
        // "allowJs": true,                       /* Allow javascript files to be compiled. */
        // "checkJs": true,                       /* Report errors in .js files. */
        // "jsx": "preserve",                     /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
        "declaration": true /* Generates corresponding '.d.ts' file. */,
        // "declarationMap": true,                /* Generates a sourcemap for each corresponding '.d.ts' file. */
        // "sourceMap": true,                     /* Generates corresponding '.map' file. */
        // "outFile": "./",                       /* Concatenate and emit output to single file. */
        "outDir": "lib/" /* Redirect output structure to the directory. */,
        // "rootDir": "./",                       /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
        // "composite": true,                     /* Enable project compilation */
        // "tsBuildInfoFile": "./",               /* Specify file to store incremental compilation information */
        // "removeComments": true,                /* Do not emit comments to output. */
        // "noEmit": true,                        /* Do not emit outputs. */
        // "importHelpers": true,                 /* Import emit helpers from 'tslib'. */
        // "downlevelIteration": true,            /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
        // "isolatedModules": true,               /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
        /* Strict Type-Checking Options */
        "strict": true /* Enable all strict type-checking options. */,
        "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
        // "strictNullChecks": true,              /* Enable strict null checks. */
        // "strictFunctionTypes": true,           /* Enable strict checking of function types. */
        // "strictBindCallApply": true,           /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
        // "strictPropertyInitialization": true,  /* Enable strict checking of property initialization in classes. */
        // "noImplicitThis": true,                /* Raise error on 'this' expressions with an implied 'any' type. */
        // "alwaysStrict": true,                  /* Parse in strict mode and emit "use strict" for each source file. */
        /* Additional Checks */
        // "noUnusedLocals": true,                /* Report errors on unused locals. */
        // "noUnusedParameters": true,            /* Report errors on unused parameters. */
        // "noImplicitReturns": true,             /* Report error when not all code paths in function return a value. */
        // "noFallthroughCasesInSwitch": true,    /* Report errors for fallthrough cases in switch statement. */
        /* Module Resolution Options */
        "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
        // "baseUrl": "./",                       /* Base directory to resolve non-absolute module names. */
        // "paths": {},                           /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
        // "rootDirs": [],                        /* List of root folders whose combined content represents the structure of the project at runtime. */
        // "typeRoots": [],                       /* List of folders to include type definitions from. */
        // "types": [],                           /* Type declaration files to be included in compilation. */
        // "allowSyntheticDefaultImports": true,  /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
        "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
        // "preserveSymlinks": true,              /* Do not resolve the real path of symlinks. */
        // "allowUmdGlobalAccess": true,          /* Allow accessing UMD globals from modules. */
        /* Source Map Options */
        // "sourceRoot": "",                      /* Specify the location where debugger should locate TypeScript files instead of source locations. */
        // "mapRoot": "",                         /* Specify the location where debugger should locate map files instead of generated locations. */
        // "inlineSourceMap": true,               /* Emit a single file with source maps instead of having a separate file. */
        // "inlineSources": true,                 /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
        /* Experimental Options */
        // "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */
        // "emitDecoratorMetadata": true,         /* Enables experimental support for emitting type metadata for decorators. */
        "sourceMap": true,
        /* Advanced Options */
        "lib": [
            "dom",
            "ES2020",
            "ES2021.WeakRef"
        ], 
        "skipLibCheck": true /* Skip type checking of declaration files. */,
        "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
    },
    "exclude": [
        "**/*.d.ts",
        "tools/**/*.ts",
        "scripts/*",
        "demo**/*",
        "tests**/*"
    ]
}

Also this is a package.json file.

{
    "name": "test",
    "version": "1.0.0",
    "type": "module",
    "description": "",
    "main": "index.js",
    "scripts": {
    },
    "keywords": [],
    "author": "",
    "license": "ISC",
    "devDependencies": {
        "@playwright/test": "^1.41.2",
        "@types/node": "^20.11.19",
        "ts-node": "^10.9.2"
    },
    "dependencies": {
        "@types/yargs": "^17.0.32",
        "mime-types": "^2.1.35",
        "playwright-utilities": "^1.0.1",
        "sqlite3": "^5.1.7",
        "yargs": "^17.7.2"
    }
}

I would like to know how to solve this problem. Thank you.

@bogeeee
Copy link

bogeeee commented Mar 6, 2024

@Rainbow-420 Sorry, i don't think it's the right discussion here for posting some random errors from an ultra long tsconfig that looks like it has nothing to do with that one from the intro comment.

@jiayixu420
Copy link

I tried to give you some specifics about the problem I am currently facing.
I'm really sorry if there's too much content and it's confusing.
But I need help with this.

@ikushlianski
Copy link

Add tsx and use it instead of node. Enjoy.

Exactly, I was going to say the same: tsx feels much smoother to work with than ts-node

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment