Question
I am deploying a basic TypeScript app to Heroku using ts-node and nodemon, but the app crashes on startup with this error:
2020-05-30T00:03:12.201106+00:00 heroku[web.1]: Starting process with command `npm start`
2020-05-30T00:03:14.405285+00:00 app[web.1]:
2020-05-30T00:03:14.405303+00:00 app[web.1]: > discordtoornamentmanager@1.0.0 start /app
2020-05-30T00:03:14.405303+00:00 app[web.1]: > ts-node src/App.ts
2020-05-30T00:03:14.405304+00:00 app[web.1]:
2020-05-30T00:03:14.833655+00:00 app[web.1]: (node:23) ExperimentalWarning: The ESM module loader is experimental.
2020-05-30T00:03:14.839311+00:00 app[web.1]: TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for /app/src/App.ts
2020-05-30T00:03:14.839312+00:00 app[web.1]: at Loader.defaultGetFormat [as _getFormat] (internal/modules/esm/get_format.js:65:15)
2020-05-30T00:03:14.839314+00:00 app[web.1]: at Loader.getFormat (internal/modules/esm/loader.js:113:42)
2020-05-30T00:03:14.839315+00:00 app[web.1]: at Loader.getModuleJob (internal/modules/esm/loader.js:244:31)
2020-05-30T00:03:14.839315+00:00 app[web.1]: at processTicksAndRejections (internal/process/task_queues.js:97:5)
2020-05-30T00:03:14.839316+00:00 app[web.1]: at Loader.import (internal/modules/esm/loader.js:178:17)
2020-05-30T00:03:14.847801+00:00 app[web.1]: npm ERR! code ELIFECYCLE
2020-05-30T00:03:14.847998+00:00 app[web.1]: npm ERR! errno 1
2020-05-30T00:03:14.848957+00:00 app[web.1]: npm ERR! discordtoornamentmanager@1.0.0 start: `ts-node src/App.ts`
2020-05-30T00:03:14.849050+00:00 app[web.1]: npm ERR! Exit status 1
2020-05-30T00:03:14.849172+00:00 app[web.1]: npm ERR!
2020-05-30T00:03:14.849254+00:00 app[web.1]: npm ERR! Failed at the discordtoornamentmanager@1.0.0 start script.
2020-05-30T00:03:14.849337+00:00 app[web.1]: npm ERR! This is probably not a problem with npm. There is likely additional logging output above.
2020-05-30T00:03:14.854859+00:00 app[web.1]:
2020-05-30T00:03:14.854998+00:00 app[web.1]: npm ERR! A complete log of this run can be found in:
2020-05-30T00_03_14_850Z-debug.log
2020-05-30T00:03:14.907689+00:00 heroku[web.1]: Process exited with status 1
2020-05-30T00:03:14.943718+00:00 heroku[web.1]: State changed from starting to crashed
My package.json is:
{
"name": "discordtoornamentmanager",
"version": "1.0.0",
"description": "",
"main": "dist/app.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "nodemon -x ts-node src/App.ts",
"start": "ts-node src/App.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@types/node": "^14.0.5",
"axios": "^0.19.2",
"discord.js": "^12.2.0",
"pg": "^8.2.1",
"reflect-metadata": "^0.1.10",
"typeorm": "0.2.25",
"typescript": "^3.9.3",
"nodemon": "^2.0.4",
"ts-node": "8.10.1"
}
}
My tsconfig.json is:
{
"compilerOptions": {
"lib": ["es6"],
"target": "es6",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "dist",
"resolveJsonModule": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "**/*.spec.ts"]
}
Why does Node/Heroku report Unknown file extension ".ts", and how should this TypeScript app be configured correctly?
Short Answer
By the end of this page, you will understand why Node.js cannot run TypeScript files directly, how ts-node, ESM, and CommonJS interact, and why your Heroku deployment fails when package.json and tsconfig.json use conflicting module systems. You will also learn practical ways to fix the problem: either compile TypeScript to JavaScript before running it, or configure ts-node correctly for the module system you chose.
Concept
TypeScript files use the .ts extension, but Node.js only understands JavaScript at runtime unless you add a tool to translate TypeScript first.
In this question, the main issue is module system mismatch:
package.jsoncontains"type": "module"tsconfig.jsoncontains"module": "commonjs"- the app is started with
ts-node src/App.ts
These settings pull Node in different directions.
What is happening?
Node.js has two main module systems:
- CommonJS: uses
require()andmodule.exports - ES Modules (ESM): uses
importandexport
When you set this in package.json:
{
"type": "module"
}
Node starts treating your project as an ESM project.
But your TypeScript compiler is set to:
{
"compilerOptions": {
"module": "commonjs"
}
}
That means TypeScript is expecting to produce CommonJS-style output.
Now add ts-node into the mix. ts-node can run TypeScript without a separate build step, but older versions and certain setups do not automatically handle ESM correctly. As a result, Node sees src/App.ts as an ES module entry file and tries to load it directly. Since plain Node does not know what .ts means, it throws:
ERR_UNKNOWN_FILE_EXTENSION
Why this matters in real programming
This matters because many runtime errors are not caused by your application logic. They happen because:
- the runtime expects JavaScript but receives TypeScript
- the project mixes ESM and CommonJS
- local development works differently from production
- cloud platforms like Heroku run exactly what your
startscript says
A good deployment setup usually avoids ambiguity:
- either compile TypeScript to JavaScript and run the compiled files
- or use a runtime tool configured specifically for ESM TypeScript
For production, compiling first is often simpler and more reliable.
Mental Model
Think of Node.js as a person who can only read one language natively: JavaScript.
TypeScript is like a document written in a slightly different language. It looks similar, but it still needs a translator.
tscis a translator that converts the document ahead of timets-nodeis a live interpreter that translates while reading
Now imagine you also tell Node what reading style to use:
"type": "module"says: “Read this as ESM.”"module": "commonjs"says: “Generate CommonJS output.”
That is like giving someone two conflicting instructions:
- “Read this as a modern import/export document”
- “But the translated output will be in an older require/module format”
If the interpreter is not configured to bridge that gap, Node gets confused and stops at the .ts file itself.
Syntax and Examples
Core idea: Node runs JavaScript, not .ts
A common production setup is:
- compile TypeScript with
tsc - run the generated JavaScript with
node
CommonJS-style production setup
package.json
{
"main": "dist/App.js",
"scripts": {
"build": "tsc",
"start": "node dist/App.js",
"dev": "nodemon --exec ts-node src/App.ts"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "es2019",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "dist",
"esModuleInterop": true,
"strict": true,
"sourceMap": true
},
"include": ["src/**/*.ts"]
}
src/App.ts
import fs from 'fs';
console.log('App started');
console.log(typeof fs.readFile);
How it works
tsccompilessrc/App.tsintodist/App.jsnode dist/App.jsruns normal JavaScript- Node never has to load a
.tsfile directly
ESM-style setup
If you really want ESM, your configuration must be consistent.
package.json
{
"type": "module",
"scripts": {
"dev": "node --loader ts-node/esm src/App.ts"
}
}
This approach depends on compatible versions of Node, TypeScript, and ts-node. It is more sensitive to configuration details.
Beginner-friendly recommendation
For Heroku or similar deployments, prefer this pattern:
- remove
"type": "module"unless you specifically need ESM - compile with
tsc - start with
node dist/...
That reduces runtime surprises.
Step by Step Execution
Consider this setup:
package.json
{
"scripts": {
"build": "tsc",
"start": "node dist/App.js"
}
}
tsconfig.json
{
"compilerOptions": {
"module": "commonjs",
"target": "es2019",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}
src/App.ts
const message: string = 'Hello from TypeScript';
console.log(message);
Step-by-step
1. Run the build script
npm run build
TypeScript reads src/App.ts and creates:
const message = 'Hello from TypeScript';
console.log(message);
inside dist/App.js.
2. Run the start script
npm start
This executes:
node dist/App.js
3. What Node sees
Node sees a .js file, which it understands natively.
4. Why your original setup fails
Your original start script is:
ts-node src/App.ts
But because package.json says "type": "module", Node enters ESM mode. In that mode, the older ts-node setup may not intercept .ts the way you expect. Node then tries to load src/App.ts directly and says:
Unknown file extension ".ts"
5. The root cause
The problem is not that the file is missing. The problem is that the runtime path to execute TypeScript is not configured consistently with the chosen module system.
Real World Use Cases
1. Deploying TypeScript apps to Heroku, Render, or Railway
Many beginners run ts-node locally and assume production should do the same. In production, teams often compile first because it is more predictable.
2. Running API servers
Express, Fastify, NestJS, and other backend apps often use TypeScript during development but run compiled JavaScript in production.
Example pattern:
- local:
ts-node,tsx, or framework dev server - production:
tscthennode dist/server.js
3. CI/CD pipelines
Build servers usually:
- install dependencies
- run tests
- compile TypeScript
- deploy built JavaScript
This avoids runtime differences between environments.
4. Monorepos and shared libraries
Large projects need clear module formats. If one package is ESM and another is CommonJS, build and runtime settings must be aligned carefully.
5. Worker scripts and cron jobs
Background jobs often fail if the environment cannot load .ts files directly. Precompiling avoids that issue.
Real Codebase Usage
In real projects, developers usually pick one of these patterns.
Pattern 1: Compile before start
Very common in production.
{
"scripts": {
"build": "tsc",
"start": "node dist/App.js"
}
}
Why teams like it:
- fewer runtime surprises
- faster startup in production
- easier debugging of deployment issues
- no need for TypeScript execution hooks at runtime
Pattern 2: Separate dev and prod scripts
{
"scripts": {
"dev": "nodemon --exec ts-node src/App.ts",
"build": "tsc",
"start": "node dist/App.js"
}
}
This is a strong beginner-friendly pattern.
Pattern 3: Guard against config mismatches
Developers avoid mixing:
"type": "module"with"module": "commonjs"
unless they fully understand the consequences.
Pattern 4: Explicit runtime decisions
Teams often decide early:
- CommonJS project: remove
type: module - ESM project: use ESM-compatible tools and scripts throughout
Pattern 5: Validation through build scripts
Production codebases often rely on build steps as validation:
npm run build
If the build succeeds, deployment is more likely to work. If production tries to execute raw .ts files directly, errors appear later and are harder to diagnose.
Common Mistakes
1. Mixing ESM and CommonJS settings
Broken setup:
{
"type": "module"
}
with:
{
"compilerOptions": {
"module": "commonjs"
}
}
Why it is a problem:
- Node treats files as ESM
- TypeScript compiles as CommonJS
- runtime expectations do not match
How to avoid it:
- either remove
"type": "module" - or switch the TypeScript config and runtime tools to a full ESM setup
2. Running ts-node in production without checking compatibility
Broken assumption:
{
"start": "ts-node src/App.ts"
}
This may work locally but fail in production because of Node version differences or ESM handling.
Better approach:
{
"build": "tsc",
"start": "node dist/App.js"
}
3. Expecting Node to understand .ts
Broken code:
node src/App.ts
Node cannot run TypeScript directly.
Use one of these instead:
ts-node src/App.ts
or:
tsc && node dist/App.js
4. Forgetting that Heroku runs the start script exactly
If npm start launches an incompatible runtime command, Heroku will fail exactly the same way.
How to avoid it:
- test the exact
startcommand locally - keep production scripts simple
5. Depending on old tool versions for newer module behavior
Older versions of ts-node had more limitations around ESM.
How to avoid it:
- prefer consistent, modern tooling
- if using ESM, verify your Node, TypeScript, and
ts-nodeversions support it - if unsure, use a compile-first workflow
Comparisons
| Approach | How it works | Pros | Cons | Good for |
|---|---|---|---|---|
ts-node src/App.ts | Runs TypeScript directly at runtime | Fast local development | More runtime/config complexity | Development |
tsc then node dist/App.js | Compiles first, then runs JS | Stable and predictable | Requires build step | Production |
| CommonJS | Uses require() semantics internally | Widely supported, simple in older setups | Less aligned with modern ESM syntax | Many backend apps |
| ESM | Uses native import/export | Modern standard | More configuration-sensitive in TypeScript runtimes | Newer projects |
CommonJS vs ESM in this question
| Setting | Your config | Meaning |
|---|---|---|
package.json | "type": "module" | Treat project as ESM |
tsconfig.json | "module": "commonjs" | Compile TypeScript as CommonJS |
start script | ts-node src/App.ts | Run TypeScript directly |
These three settings do not align cleanly.
Best practical comparison
- If you want simplicity, use CommonJS + build before start.
- If you want native ESM, configure everything consistently for ESM and use an ESM-capable runtime path.
Cheat Sheet
Quick rules
- Node does not run
.tsfiles natively ts-nodecan run.ts, but its configuration must match your module system"type": "module"makes Node treat the project as ESM"module": "commonjs"tells TypeScript to output CommonJS- Mixing those settings often causes runtime errors
Safe production setup
package.json
{
"scripts": {
"build": "tsc",
"start": "node dist/App.js"
}
}
tsconfig.json
{
"compilerOptions": {
"module": "commonjs",
"outDir": "dist"
}
}
Build and run
npm run build
npm start
If you use CommonJS
- remove
"type": "module"frompackage.json - keep
"module": "commonjs"intsconfig.json
If you use ESM
- keep
"type": "module" - use ESM-compatible TypeScript settings and runtime commands
- make sure your
ts-nodeand Node versions support that setup
Red flags
ERR_UNKNOWN_FILE_EXTENSIONCannot use import statement outside a moduleexports is not defined in ES module scope
These often mean your module settings are mismatched.
FAQ
Why does Node say .ts is an unknown file extension?
Because Node only understands JavaScript by default. A .ts file must be compiled first or handled by a compatible runtime tool such as ts-node.
Why did this happen on Heroku but maybe not locally?
Production environments may use a different Node version or load modules differently. Heroku also runs your start script exactly as defined, so configuration problems become obvious there.
Is ts-node good for production?
It can work, but many teams prefer compiling with tsc and running JavaScript in production because it is simpler and more reliable.
What is wrong with using "type": "module" and "module": "commonjs" together?
They describe different module systems. Node will expect ESM behavior, while TypeScript will compile for CommonJS. That mismatch often causes runtime errors.
Should I remove "type": "module"?
If your project is meant to use CommonJS and you want the simplest fix, yes. Remove it and compile to JavaScript before running.
What is the easiest fix for this specific setup?
Use a build step with tsc, remove "type": "module", and start the compiled file with node dist/App.js.
Can I still use nodemon in development?
Yes. A common setup is nodemon --exec ts-node src/App.ts for development and node dist/App.js for production.
Mini Project
Description
Create a small TypeScript command-line app that starts reliably in both development and production. The project demonstrates the safest beginner-friendly deployment pattern: use ts-node for local development and compile with tsc for production.
Goal
Build a TypeScript app that prints a startup message locally and can be deployed without .ts runtime errors.
Requirements
- Create a
src/App.tsfile that logs a message and the current environment. - Add a development script that runs the TypeScript file directly.
- Add a build script that compiles TypeScript into a
distfolder. - Add a production start script that runs the compiled JavaScript.
- Configure the project so it uses one consistent module system.
Keep learning
Related questions
@Directive vs @Component in Angular: Differences, Use Cases, and When to Use Each
Learn the difference between @Directive and @Component in Angular, including use cases, examples, and when to choose each.
Accessing Input Value from EventTarget in TypeScript
Learn why EventTarget has no value property in TypeScript and safely read values from HTML input events in Angular applications.
Angular (change) vs (ngModelChange): What’s the Difference?
Learn the difference between Angular (change) and (ngModelChange), when each fires, and which one to use in forms and inputs.