Skip to content

Instantly share code, notes, and snippets.

@coder-mike
Last active June 14, 2024 22:21
Show Gist options
  • Save coder-mike/7f0492abeba2b1216c02c596382a74a4 to your computer and use it in GitHub Desktop.
Save coder-mike/7f0492abeba2b1216c02c596382a74a4 to your computer and use it in GitHub Desktop.
TypeScript on Node
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Unit Tests",
"args": [
"--enable-source-maps",
"--test-only",
"--test"
],
"skipFiles": [
"<node_internals>/**"
],
"sourceMaps": true,
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
],
"console": "internalConsole",
"preLaunchTask": "Build TypeScript (Watch)"
}
]
}
// .vscode/tasks.json
{
"version": "2.0.0",
"tasks": [
{
"label": "Build TypeScript (Watch)",
"type": "shell",
"command": "npm run build:watch",
"group": {
"kind": "build",
"isDefault": true
},
"isBackground": true,
"problemMatcher": "$tsc-watch"
},
]
}

TypeScript on Node.js in VS Code

This gist is the setup and workflow I use to develop TypeScript on node.js. This readme justifies the choices made here. Feel free to recommend better choices.

Run Tests

npm install
npm build  # or `npm build:watch` or `Ctrl+Shift+B`
npm test

Debug a Test (VS Code)

  1. Mark the corresponding describe and it with .only (see node:test docs).
  2. Run the debugger (F5)

Note: the included VS Code launch profile runs node with --test-only so it will only execute tests that are marked as only. Unlike some other frameworks, node:test requires that you mark the whole ancestor chain with only (i.e. all the nested describe blocks).

My Dev Process

  1. Open project
  2. Run continuous-build task
  3. Write code
  4. Write tests
  5. Run tests
  6. Mark failing test with .only (and its ancestor describe blocks).
  7. Add relevent breakpoints.
  8. Run debug launch configuration in VS Code.
  9. Step through the code.
  10. Notice an unexpected state. Look for source of the unexpected state:
  11. Set breakpoints earlier in the program, where the offending state should have been created.
  12. Restart the debugger (Ctrl+Shift+F5). Go to step 8.
  13. Fix the source of the problem.
  14. Go to step 4.

Choices

node:test and node:assert for testing

Because they're builtin, so it's fewer dependencies and I expect it to get the best support going forwards.

Using .only with a launch configuration to debug a single test

I've found that when I'm debugging a test failure, I'll rerun the failing test many times before getting to the bottom of the issue. Test cases can be run in VS Code using the relevant VS Code test extension, which for node:test is the extension node:test runner.

But for me it's not acceptable to run a test each time by:

  1. Navigating to the test file (which you're likely not in, because you're debugging one of your source files).
  2. Scrolling to the top of the test (if it's off screen). If there's multiple tests then you have to make sure you're in the right one.
  3. Moving the mouse to the little icon next to the top of the test and right clicking it.
  4. Moving the mouse to the "debug" button and clicking it.

Contrast this with just pressing F5 and the test will run again no matter which code file you're in.

Marking a test as .only is a quick change at the beginning of your debug process that leads to a much faster debug loop time.

Another reason to use a launch configuration to debug tests rather than specifically the node:test runner VS Code extension, is that I like to debug tests with "Caught Exceptions" breakpoints enabled. This is a huge productivity boost because in the case where your problem is visible as a thrown exception, you'll jump straight to the point where it's thrown and can immediately start inspecting the state that caused the exception. But this is only a reasonable approach if you don't throw exceptions as a normal course of events. Unfortunately, at least on my machine at the time of this writing, the node:test runner extension throws an exception as part of its normal startup process, which to me makes it incompatible with the enabling "Caught Exceptions" breakpoints.

Continuous background build

The npm build:watch task (or default build task in VS Code, with ctrl + shift + B, thanks to .vscode/tasks.json) will continuously build the project in the background.

This is to improve the rate of feedback during dev:

  • Continuous build as a background task immediately shows errors across the whole project when you make changes.
  • Continuous build is much faster than manually building as a discrete step with npm build.
  • npm test is super fast because it doesn't need to compile TypeScript files at all.
  • Reduced delay between starting the debugger and actually debugging your code.
  • Since the debugger is running JavaScript, you won't see a loader (like tsx) in your call stack.

"type": "module" in package.json

The combination of this and the "Node16" module configuration in tsconfig.json means that TSC is outputting clean ESM module code. This is again mainly to improve the developer experience.

When stepping into a function in another module, if there is generated scaffolding CommonJS code then often you step into that first (the debugger jumps to the top of the file) before stepping through to your actual code. By having the output be ESM, the debug stepping experience is cleaner. This applies to other kinds of scaffolding code as well which subtly slows the debug experience.

Anti Choices (what I didn't choose)

Don't build automatically upon npm install

The problem is that npm install only happens once, but for a dev cycle you need to keep building every time something changes.

Don't build automatically upon npm test

Incorporating npm build as a pre-step into npm test means that the tests are guaranteed to be testing the latest TypeScript code, but it comes at a significant cost of performance since then npm test implicitly must build the whole project. This slows down the development-debug cycle because it takes longer to test.

Don't use a TypeScript loader on npm test

Another option is to have a loader, such as node --import tsx ... to compile the TypeScript on the fly. This has the advantage that you don't need to explicitly build, but the disadvantage that it's again much slower to run the tests because they need to be compiled each time.

{
"type": "module",
"scripts": {
"build": "tsc",
"build:watch": "tsc -w",
"test": "node --enable-source-maps --test",
"test:coverage": "node --enable-source-maps --test --experimental-test-coverage"
},
"devDependencies": {
"@types/node": "^20.14.2",
"typescript": "^5.4.5"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"inlineSources": true,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment