Question

How to Fix "Unknown file extension .ts" in TypeScript on Heroku

typescriptnode.jsnpm

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.json contains "type": "module"
  • tsconfig.json contains "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() and module.exports
  • ES Modules (ESM): uses import and export

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 start script 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.

  • tsc is a translator that converts the document ahead of time
  • ts-node is 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.

Take Quiz

Syntax and Examples

Core idea: Node runs JavaScript, not .ts

A common production setup is:

  1. compile TypeScript with tsc
  2. 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

  • tsc compiles src/App.ts into dist/App.js
  • node dist/App.js runs normal JavaScript
  • Node never has to load a .ts file 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: tsc then node 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.

Take Quiz

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 start command 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-node versions support it
  • if unsure, use a compile-first workflow

Comparisons

ApproachHow it worksProsConsGood for
ts-node src/App.tsRuns TypeScript directly at runtimeFast local developmentMore runtime/config complexityDevelopment
tsc then node dist/App.jsCompiles first, then runs JSStable and predictableRequires build stepProduction
CommonJSUses require() semantics internallyWidely supported, simple in older setupsLess aligned with modern ESM syntaxMany backend apps
ESMUses native import/exportModern standardMore configuration-sensitive in TypeScript runtimesNewer projects

CommonJS vs ESM in this question

SettingYour configMeaning
package.json"type": "module"Treat project as ESM
tsconfig.json"module": "commonjs"Compile TypeScript as CommonJS
start scriptts-node src/App.tsRun 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.
Take Quiz

Cheat Sheet

Quick rules

  • Node does not run .ts files natively
  • ts-node can 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" from package.json
  • keep "module": "commonjs" in tsconfig.json

If you use ESM

  • keep "type": "module"
  • use ESM-compatible TypeScript settings and runtime commands
  • make sure your ts-node and Node versions support that setup

Red flags

  • ERR_UNKNOWN_FILE_EXTENSION
  • Cannot use import statement outside a module
  • exports 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.

Take Quiz

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.ts file 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 dist folder.
  • Add a production start script that runs the compiled JavaScript.
  • Configure the project so it uses one consistent module system.
Take Quiz

Keep learning

Related questions

View more