Migrating a TypeScript Application with ts-node to Watt
Problem
You have a standalone TypeScript application using ts-node for development, and you want to migrate it to Platformatic Watt while preserving your existing TypeScript workflow. You need to:
- Retain ts-node for direct TypeScript execution in development
- Maintain your current development experience without requiring builds
- Take advantage of Watt's features (hot reload, multi-application support, etc.)
- Keep development production builds working as before
Your TypeScript code uses modern import and export statements, but you might not be familiar with the differences between JavaScript module systems (like "ESM" and "CommonJS"). This guide will help you understand what's needed without assuming prior knowledge.
When to use this solution:
- Migrating existing TypeScript applications to Platformatic Watt
- Teams with established ts-node development workflows
- Applications that need Watt's orchestration while keeping TypeScript tooling
- Projects transitioning to Platformatic without rewriting build processes
Solution Overview
This guide walks you through migrating a standalone TypeScript application to Watt while keeping ts-node. We'll cover:
- Understanding your current standalone setup
- Understanding JavaScript module systems (and why it matters)
- Configuring Watt to use ts-node with
execArgv - Adjusting your development and production workflows
- Troubleshooting common module system issues
Your Current Standalone Setup
This section shows an example of a typical standalone TypeScript application structure. Your actual setup may differ, but the principles in this guide apply regardless of your specific configuration.
Most TypeScript applications use modern import and export syntax in their TypeScript files as defined in the ESM module format. However, many of these applications are configured to compile down to an older JavaScript module format called "CommonJS" for runtime execution. This is what we call a "faux ESM" setup - your TypeScript code looks modern, but it runs as CommonJS module format.
Let's look at a typical standalone TypeScript application structure:
Directory structure:
my-app/
├── src/
│ └── index.ts
├── package.json
├── tsconfig.json
└── node_modules/
package.json:
{
"name": "my-app",
"main": "dist/index.js",
"scripts": {
"dev": "ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"fastify": "^5.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}
Notice: This package.json does not have "type": "module". This is the typical setup for many TypeScript applications.
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "dist",
"strict": true,
"esModuleInterop": true
}
}
Notice: The "module": "commonjs" setting means TypeScript will compile your modern import/export syntax into CommonJS require() calls.
src/index.ts:
import { createServer } from 'node:http'
// Using the legacy enum syntax will make this file not loadable via Node.js native type stripping
enum Environment {
Development = 'development',
Production = 'production'
}
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
environment: Environment[process.env.NODE_ENV as keyof typeof Environment] ?? Environment.Development
})
)
})
server.listen(3000)
Your TypeScript code uses import statements (which look modern), but when you build with tsc, it gets converted to CommonJS. When you run npm run dev, ts-node executes your TypeScript directly in CommonJS mode. Everything works, and you may not have thought about the module system at all. Now let's migrate this to Watt.
Understanding JavaScript Module Systems
Before we migrate, it's helpful to understand what's happening under the hood. JavaScript has two main module systems:
CommonJS (the older system):
- Uses
require()to import modules andmodule.exportsto export - Example:
const http = require('http') - Default in Node.js for many years
- Still very common and fully supported
ESM (ECMAScript Modules, the newer system):
- Uses
importandexportstatements - Example:
import http from 'http' - Native JavaScript standard
- Requires special configuration in Node.js
The Faux ESM situation:
TypeScript lets you write modern import/export syntax regardless of which module system you're actually using at runtime. Most TypeScript projects are configured to compile those modern imports into CommonJS require() calls. This is perfectly fine and very common!
Why this matters for Watt:
When migrating to Watt with ts-node, we need to tell ts-node which module system to use. Since your current setup uses CommonJS (even though you write import statements in TypeScript), we'll configure ts-node to use CommonJS mode with the -r ts-node/register flag.
Note: If you want to use true ESM instead, you'll need to change your tsconfig.json and package.json settings. We'll show you how at the end of this guide.
Migration Steps
This migration uses the standalone Node.js capability approach for a simpler single-application setup. We'll configure Watt to use ts-node in CommonJS mode, matching your current setup.
Step 1: Install Dependencies
npm install wattpm @platformatic/globals @platformatic/node
npm install -D @platformatic/tsconfig
Step 2: Create Watt Configuration
Create watt.json in your project root:
{
"$schema": "https://schemas.platformatic.dev/@platformatic/node/3.25.0.json",
"application": {
"commands": {
"build": "tsc -p ."
}
},
"node": {
"disableBuildInDevelopment": true
},
"runtime": {
"application": {
"execArgv": ["-r", "ts-node/register"]
},
"server": {
"port": 3000
}
}
}
Key configuration details:
- Uses
@platformatic/nodeschema application.commands.build: "tsc -p ."defines the build command that will be used in production-p .tells TypeScript to use the tsconfig.json in the current directory
node.disableBuildInDevelopment: trueensures TypeScript runs directly in dev mode without needing to build firstruntime.application.execArgvconfigures how Node.js runs your TypeScript files:"-r", "ts-node/register"registers ts-node for CommonJS module loading
runtime.server.port: 3000sets the default server port
This configuration provides maximum compatibility - it works whether your TypeScript compiles to CommonJS or ESM, automatically handling both module systems.
Step 3: Update Package.json
Update your package.json to use Watt commands:
{
"name": "my-app",
"main": "src/index.ts",
"scripts": {
"dev": "wattpm dev",
"build": "wattpm build",
"start": "wattpm start"
},
"dependencies": {
"@platformatic/globals": "^3.25.0",
"@platformatic/node": "^3.25.0",
"wattpm": "^3.25.0"
},
"devDependencies": {
"@platformatic/tsconfig": "^0.1.0",
"@types/node": "^22.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}
Notice: We kept everything the same as your original setup - no "type": "module" field, since we're sticking with CommonJS mode. The main changes are just the npm scripts to use wattpm commands instead of running ts-node directly.
Migrating to True ESM
If you want to use true ECMAScript Modules (ESM) instead of CommonJS, you'll need to update several configuration files. This section shows you how to convert your application to use native ESM.
When to Use ESM
Consider migrating to ESM if you:
- Want to use modern JavaScript features and native module syntax
- Need to import ESM-only packages (many newer npm packages only support ESM)
- Prefer the standardized module system over CommonJS
ESM Migration Steps
1. Update package.json to enable ESM:
Add "type": "module" to tell Node.js to treat .js files as ESM:
{
"name": "my-app",
"type": "module",
"main": "src/index.ts",
"scripts": {
"dev": "wattpm dev",
"build": "wattpm build",
"start": "wattpm start"
},
"dependencies": {
"@platformatic/globals": "^3.25.0",
"@platformatic/node": "^3.25.0",
"wattpm": "^3.25.0"
},
"devDependencies": {
"@platformatic/tsconfig": "^0.1.0",
"@types/node": "^22.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}
2. Update tsconfig.json for ESM output:
Change your TypeScript configuration to compile to ESM:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"strict": true,
"esModuleInterop": true
}
}
Key changes:
"module": "NodeNext"tells TypeScript to emit ESM-compatible code"moduleResolution": "NodeNext"uses Node.js's ESM resolution algorithm
3. Update watt.json to use ESM loader:
Change the execArgv configuration to use ts-node's ESM loader:
{
"$schema": "https://schemas.platformatic.dev/@platformatic/node/3.25.0.json",
"application": {
"commands": {
"build": "tsc -p ."
}
},
"node": {
"disableBuildInDevelopment": true
},
"runtime": {
"application": {
"execArgv": ["--loader", "ts-node/esm"]
},
"server": {
"port": 3000
}
}
}
The key change is "execArgv": ["--loader", "ts-node/esm"] which registers ts-node's ESM loader instead of the CommonJS register hook.
4. Update your imports (if needed):
With ESM, you may need to add file extensions to relative imports in some cases. However, TypeScript typically handles this for you:
// This works in ESM
import { myFunction } from './myModule.js' // Note: .js extension even though source is .ts
Your TypeScript code doesn't need to change if you're already using import/export syntax, but be aware that:
- File extensions may be required for relative imports (TypeScript config handles this) and
index.jsfiles are not resolved automatically __dirnameand__filenameare not available in ESM (useimport.meta.filenameorimport.meta.dirnameinstead)- Top-level
awaitis supported in ESM
5. Test your application:
Run your development server to ensure everything works:
npm run dev
Your application should now be running with true ESM! The behavior should be identical to before, but you're now using the native JavaScript module system.
Important ESM Notes
- File extensions: When importing local files in ESM, you reference the compiled
.jsextension even though your source files are.ts - No
__dirnameor__filename: Useimport.meta.filenameorimport.meta.dirnameinstead. For example: - Named imports: ESM is more strict about named vs. default imports. You may need to adjust how you import some packages
- Top-level await: You can use
awaitat the top level of your modules without wrapping in an async function
Faster Development Startup
By default, ts-node checks your TypeScript for errors every time it runs. This is helpful but can slow down startup, especially in large projects. If you want faster startup, you can use "transpile-only" mode, which skips the type checking.
For CommonJS mode (the default setup in this guide):
"execArgv": ["-r", "ts-node/register/transpile-only"]
For ESM mode (if you migrated to true ESM):
"execArgv": ["--loader", "ts-node/esm/transpile-only"]
When using transpile-only mode, ts-node will just convert your TypeScript to JavaScript without checking for errors. You should run npx tsc --noEmit separately (e.g., in a pre-commit hook or CI pipeline) to catch type errors.
Node.js 22+ Built-in TypeScript Support
If you're using Node.js 22 or later and only need type stripping (similar to transpile-only mode), you don't need ts-node at all! Node.js 22+ includes experimental built-in support for running TypeScript files directly.
When is Native Type Stripping Available?
Node.js built-in type stripping only strips types and doesn't transpile TypeScript-specific syntax. Your code should only use type annotations and standard JavaScript syntax.
Avoid these TypeScript-specific features:
- Legacy enums (use const enums or plain objects instead)
- Decorators (not supported yet)
- Parameter properties (use explicit class properties)
- Namespace syntax (use ES modules instead)
Migration Steps from ts-node
1. Update watt.json to use Node.js built-in type stripping:
{
"$schema": "https://schemas.platformatic.dev/@platformatic/node/3.25.0.json",
"runtime": {
"server": {
"port": 3000
}
}
}
2. Remove ts-node from package.json:
npm uninstall ts-node
3. Update your package.json (if needed):
If your package.json referenced the dist folder for production builds, you can now directly point to the src/index.ts file.
{
"name": "my-app",
"main": "src/index.ts",
"scripts": {
"dev": "wattpm dev",
"build": "wattpm build",
"start": "wattpm start"
},
"dependencies": {
"@platformatic/globals": "^3.25.0",
"@platformatic/node": "^3.25.0",
"wattpm": "^3.25.0"
},
"devDependencies": {
"@platformatic/tsconfig": "^0.1.0",
"@types/node": "^22.0.0",
"typescript": "^5.9.3"
}
}
4. Review your TypeScript code for unsupported features:
Node.js built-in type stripping only strips types and doesn't transpile TypeScript-specific syntax. Check your code for:
- Legacy enums (use const enums or plain objects instead)
- Decorators (not supported yet)
- Parameter properties (use explicit class properties)
- Namespace syntax (use ES modules instead)
For example, the enum in our example code won't work with built-in type stripping. You'd need to change it to:
// Before (won't work with --experimental-strip-types)
enum Environment {
Development = 'development',
Production = 'production'
}
// After (works with --experimental-strip-types)
const Environment = {
Development: 'development',
Production: 'production'
} as const
Important Notes
- It only strips types - no transpilation of other TypeScript features like legacy enums, decorators or JSX
- Type checking is not performed (similar to transpile-only mode), so run
npx tsc --noEmitseparately to catch type errors - Works with both CommonJS and ESM (configured via your package.json and tsconfig.json as usual)
- The
distfolder is still created during production builds viatsc, so you don't need to update any deployment configurations
This is the simplest approach if you're on Node.js 22+ and don't use advanced TypeScript features beyond type annotations.
Using swc-node as an Alternative
While ts-node is the most popular choice for running TypeScript directly, there are faster alternatives that you might want to consider.
swc-node uses SWC (Speedy Web Compiler), a Rust-based transpiler that's extremely fast.
Installation:
npm install -D @swc-node/register @swc/core
Update watt.json in your project root:
{
"$schema": "https://schemas.platformatic.dev/@platformatic/node/3.25.0.json",
"runtime": {
"application": {
"execArgv": ["--import", "@swc-node/register/esm-register"]
}
},
"node": {
"disableBuildInDevelopment": true
}
}
Using tsx as an Alternative
tsx (TypeScript Execute) is a Node.js enhancement powered by esbuild that runs TypeScript files directly. It offers fast startup times and seamless interoperability between CommonJS and ESM modules. Unlike ts-node, tsx handles both module systems without separate configuration flags.
Important: tsx must be used with the commands configuration approach in Watt, not with execArgv. This is because tsx hooks into Node.js module resolution in a way that interferes with Watt's internal module loading when registered via execArgv. The commands approach runs tsx in a separate child process, avoiding this conflict.
Installation
npm install -D tsx
Ensure you are using tsx v4.20.4 or later for compatibility with Node.js 22.18+ and 24+, which have native type stripping enabled by default.
ESM Configuration
Use this configuration when your project uses "type": "module" in package.json (true ECMAScript Modules).
package.json:
{
"name": "my-app",
"type": "module",
"main": "src/index.ts",
"scripts": {
"dev": "wattpm dev",
"build": "wattpm build",
"start": "wattpm start"
},
"dependencies": {
"@platformatic/node": "^3.25.0",
"wattpm": "^3.25.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"strict": true,
"esModuleInterop": true
}
}
watt.json:
{
"$schema": "https://schemas.platformatic.dev/@platformatic/node/3.25.0.json",
"application": {
"commands": {
"development": "node --no-experimental-strip-types --no-experimental-transform-types --import tsx src/index.ts",
"build": "tsc -p .",
"production": "node dist/index.js"
}
},
"watch": {
"enabled": true
},
"runtime": {
"server": {
"port": 3000
}
}
}
Key configuration details:
commands.developmentruns tsx directly with Node.js flags to disable native type handling--no-experimental-strip-typesand--no-experimental-transform-typesdisable Node.js 22+/24+ built-in TypeScript processing, ensuring tsx handles all TypeScript compilation. This avoids conflicts between Node's native type stripping and tsx's own TypeScript handling--import tsxregisters tsx's ESM and CJS loaderscommands.buildcompiles TypeScript for production usingtsccommands.productionruns the compiled JavaScript output
CommonJS Configuration
Use this configuration when your project does not have "type": "module" in package.json (the default CommonJS mode).
package.json:
{
"name": "my-app",
"main": "src/index.ts",
"scripts": {
"dev": "wattpm dev",
"build": "wattpm build",
"start": "wattpm start"
},
"dependencies": {
"@platformatic/node": "^3.25.0",
"wattpm": "^3.25.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}
Notice: No "type": "module" field — this keeps the project in CommonJS mode.
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "dist",
"strict": true,
"esModuleInterop": true
}
}
watt.json:
{
"$schema": "https://schemas.platformatic.dev/@platformatic/node/3.25.0.json",
"application": {
"commands": {
"development": "node --no-experimental-strip-types --no-experimental-transform-types --import tsx src/index.ts",
"build": "tsc -p .",
"production": "node dist/index.js"
}
},
"watch": {
"enabled": true
},
"runtime": {
"server": {
"port": 3000
}
}
}
The --import tsx flag works for both ESM and CommonJS projects — tsx automatically detects the module system based on your package.json and tsconfig.json settings.
Why Not execArgv?
Unlike ts-node and swc-node, tsx cannot be used with the execArgv configuration in Watt:
{
"runtime": {
"application": {
"execArgv": ["--import", "tsx"]
}
}
}
The above configuration will not work and will produce MODULE_NOT_FOUND errors.
When tsx is registered via execArgv, it runs inside Watt's worker thread and intercepts all module resolution — including Watt's own internal modules. This causes MODULE_NOT_FOUND errors for packages like @platformatic/node because tsx's resolveTsPaths hook interferes with Watt's internal require() calls.
The commands approach avoids this by running tsx in a separate child process where it only affects your application code.
Troubleshooting
ERR_PACKAGE_PATH_NOT_EXPORTED
If you encounter ERR_PACKAGE_PATH_NOT_EXPORTED errors when using tsx, this happens because tsx internally converts some imports to require() calls, which causes the import condition in package exports to be lost. Packages with conditional exports (like execa, unicorn-magic, or packages using "exports" with separate "import" and "require" paths) may fail to resolve correctly.
Fix: Add --conditions import to the Node.js command in your development command:
{
"application": {
"commands": {
"development": "node --no-experimental-strip-types --no-experimental-transform-types --import tsx --conditions import src/index.ts"
}
}
}
The --conditions import flag tells Node.js to apply the import condition during module resolution, ensuring packages with conditional exports resolve correctly even when tsx uses require() internally.
Worker Threads Inside Your Application
If your application creates its own Worker threads (not the workers managed by Watt), tsx won't automatically propagate to those child workers. You need to register tsx programmatically inside the worker:
// In your worker file
import { register } from 'tsx/esm/api'
register()
// Now you can import TypeScript files
import { myFunction } from './my-module.ts'
Alternatively, use the eval pattern when creating the worker:
import { Worker } from 'node:worker_threads'
const workerPath = new URL('./my-worker.ts', import.meta.url).href
const worker = new Worker(
`import('tsx/esm/api').then(({ register }) => { register(); import('${workerPath}') })`,
{ eval: true }
)
This is not a Watt-specific issue — it's a general limitation of tsx with Node.js worker threads.