Back to blog

Node.js 25: Native TypeScript Without Transpilation Changes Everything in Backend JavaScript

Hello HaWkers, one of the most awaited changes by the JavaScript community has finally matured in Node.js 25. Now you can execute TypeScript files directly with node index.ts without needing ts-node, Babel, or any build step.

Have you ever thought about completely eliminating the transpilation step from your development workflow? That reality has arrived.

What Changed in Node.js 25

Node.js 25, released as the Current version in November 2025, consolidates native TypeScript support that started experimental in previous versions. The feature is now stable and ready for production use.

How It Works

Node.js uses a technique called type stripping - it removes TypeScript types at runtime without doing any code transformation. This means your TypeScript code executes at practically the same speed as pure JavaScript.

# Before - traditional workflow with TypeScript
npx tsc src/index.ts --outDir dist
node dist/index.js

# Or with ts-node
npx ts-node src/index.ts

# Now - Node.js 25+
node src/index.ts

Zero Configuration

The beauty of this feature is that it requires no special configuration:

# Node.js 25 installation
nvm install 25
nvm use 25

# Create a TypeScript file
echo 'const message: string = "Hello, TypeScript!";
console.log(message);' > hello.ts

# Execute directly
node hello.ts
# Output: Hello, TypeScript!

Practical Examples

Express API with TypeScript

See how simple it is to create an Express API with full typing:

// server.ts - execute with: node server.ts
import express, { Request, Response, NextFunction } from 'express';

interface User {
  id: number;
  name: string;
  email: string;
  createdAt: Date;
}

interface ApiResponse<T> {
  success: boolean;
  data: T;
  message?: string;
}

const app = express();
app.use(express.json());

// Simulated database with typing
const users: Map<number, User> = new Map();
let nextId = 1;

// Typed logging middleware
const logRequest = (req: Request, res: Response, next: NextFunction): void => {
  console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
  next();
};

app.use(logRequest);

// GET /users - List all users
app.get('/users', (req: Request, res: Response<ApiResponse<User[]>>) => {
  const allUsers = Array.from(users.values());

  res.json({
    success: true,
    data: allUsers
  });
});

// POST /users - Create a new user
app.post('/users', (req: Request, res: Response<ApiResponse<User>>) => {
  const { name, email } = req.body as { name: string; email: string };

  const newUser: User = {
    id: nextId++,
    name,
    email,
    createdAt: new Date()
  };

  users.set(newUser.id, newUser);

  res.status(201).json({
    success: true,
    data: newUser,
    message: 'User created successfully'
  });
});

// GET /users/:id - Get user by ID
app.get('/users/:id', (req: Request, res: Response<ApiResponse<User | null>>) => {
  const userId = parseInt(req.params.id);
  const user = users.get(userId);

  if (!user) {
    res.status(404).json({
      success: false,
      data: null,
      message: 'User not found'
    });
    return;
  }

  res.json({
    success: true,
    data: user
  });
});

const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
  console.log(`Try: curl http://localhost:${PORT}/users`);
});

Typed Automation Scripts

For automation scripts and CLI tools, native TypeScript is perfect:

// deploy-script.ts - execute with: node deploy-script.ts
import { execSync } from 'child_process';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';

interface DeployConfig {
  environment: 'staging' | 'production';
  branch: string;
  buildCommand: string;
  deployTarget: string;
}

interface DeployResult {
  success: boolean;
  duration: number;
  commitHash: string;
  timestamp: Date;
}

function loadConfig(env: string): DeployConfig {
  const configPath = join(process.cwd(), `deploy.${env}.json`);

  if (!existsSync(configPath)) {
    throw new Error(`Config file not found: ${configPath}`);
  }

  const content = readFileSync(configPath, 'utf-8');
  return JSON.parse(content) as DeployConfig;
}

function executeCommand(command: string): string {
  console.log(`Executing: ${command}`);
  return execSync(command, { encoding: 'utf-8' });
}

function getCurrentCommit(): string {
  return executeCommand('git rev-parse HEAD').trim();
}

async function deploy(environment: string): Promise<DeployResult> {
  const startTime = Date.now();

  console.log(`\n🚀 Starting deployment to ${environment}\n`);

  const config = loadConfig(environment);
  const commitHash = getCurrentCommit();

  console.log(`📋 Configuration loaded:`);
  console.log(`   Environment: ${config.environment}`);
  console.log(`   Branch: ${config.branch}`);
  console.log(`   Commit: ${commitHash.substring(0, 8)}`);

  // Verify branch
  const currentBranch = executeCommand('git branch --show-current').trim();
  if (currentBranch !== config.branch) {
    throw new Error(
      `Wrong branch! Expected ${config.branch}, got ${currentBranch}`
    );
  }

  // Build
  console.log(`\n🔨 Building...`);
  executeCommand(config.buildCommand);

  // Deploy
  console.log(`\n📦 Deploying to ${config.deployTarget}...`);
  executeCommand(`rsync -avz ./dist/ ${config.deployTarget}`);

  const duration = Date.now() - startTime;

  const result: DeployResult = {
    success: true,
    duration,
    commitHash,
    timestamp: new Date()
  };

  console.log(`\n✅ Deployment completed in ${duration}ms`);

  return result;
}

// Execution
const env = process.argv[2] || 'staging';

deploy(env)
  .then(result => {
    console.log('\nDeploy Result:', result);
  })
  .catch(error => {
    console.error('\n❌ Deployment failed:', error.message);
    process.exit(1);
  });

Worker Threads with TypeScript

Worker Threads support also works perfectly:

// main.ts
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';
import { cpus } from 'os';

interface WorkerTask {
  id: number;
  data: number[];
  operation: 'sum' | 'average' | 'max' | 'min';
}

interface WorkerResult {
  id: number;
  result: number;
  processedBy: number;
}

if (isMainThread) {
  // Main code
  const numCPUs = cpus().length;
  console.log(`Main thread running with ${numCPUs} CPU cores available`);

  const tasks: WorkerTask[] = [
    { id: 1, data: [1, 2, 3, 4, 5], operation: 'sum' },
    { id: 2, data: [10, 20, 30, 40, 50], operation: 'average' },
    { id: 3, data: [5, 15, 3, 22, 8], operation: 'max' },
    { id: 4, data: [100, 50, 75, 25, 200], operation: 'min' }
  ];

  const processTask = (task: WorkerTask): Promise<WorkerResult> => {
    return new Promise((resolve, reject) => {
      // Worker executing the same .ts file
      const worker = new Worker(new URL(import.meta.url), {
        workerData: task
      });

      worker.on('message', resolve);
      worker.on('error', reject);
    });
  };

  // Process all tasks in parallel
  Promise.all(tasks.map(processTask))
    .then(results => {
      console.log('\nResults:');
      results.forEach(r => {
        console.log(`  Task ${r.id}: ${r.result} (Worker ${r.processedBy})`);
      });
    });

} else {
  // Worker code
  const task = workerData as WorkerTask;

  const operations = {
    sum: (arr: number[]) => arr.reduce((a, b) => a + b, 0),
    average: (arr: number[]) => arr.reduce((a, b) => a + b, 0) / arr.length,
    max: (arr: number[]) => Math.max(...arr),
    min: (arr: number[]) => Math.min(...arr)
  };

  const result: WorkerResult = {
    id: task.id,
    result: operations[task.operation](task.data),
    processedBy: process.pid
  };

  parentPort?.postMessage(result);
}

Other Node.js 25 News

Besides native TypeScript, Node.js 25 brings other significant improvements.

V8 Engine 13.6

The new V8 version brings performance optimizations:

// Improvements in Array methods
const numbers: number[] = Array.from({ length: 1_000_000 }, (_, i) => i);

// Optimized Array.prototype.at()
const last = numbers.at(-1);  // Faster than numbers[numbers.length - 1]

// Optimized iterators
const doubled = numbers.values().map(n => n * 2).toArray();

// Object.groupBy() with better performance
interface Product {
  category: string;
  name: string;
  price: number;
}

const products: Product[] = [
  { category: 'electronics', name: 'Phone', price: 999 },
  { category: 'electronics', name: 'Laptop', price: 1499 },
  { category: 'clothing', name: 'Shirt', price: 49 }
];

const grouped = Object.groupBy(products, p => p.category);
// { electronics: [...], clothing: [...] }

Enhanced Permission Model

The permission model is more robust:

# Run with restricted permissions
node --permission --allow-fs-read=/app/config --allow-fs-write=/app/logs server.ts

# Allow only specific network access
node --permission --allow-net=api.example.com:443 client.ts

Integrated npm 11

npm 11 comes included with improvements:

# Faster workspaces
npm install --workspaces

# Better dependency resolution
npm install --prefer-dedupe

# Enhanced audit
npm audit --json

Limitations and Considerations

Although revolutionary, native TypeScript in Node.js has some important limitations.

What Does NOT Work

1. Enums are not supported

// ❌ Does not work
enum Status {
  Active = 'active',
  Inactive = 'inactive'
}

// ✅ Use const objects or union types
const Status = {
  Active: 'active',
  Inactive: 'inactive'
} as const;

type StatusType = typeof Status[keyof typeof Status];

2. Experimental decorators

// ❌ Decorators don't work natively
@Injectable()
class UserService {}

// ✅ Use alternative patterns or continue with transpilation

3. TypeScript namespaces

// ❌ Namespaces not supported
namespace MyApp {
  export interface User {}
}

// ✅ Use ES Modules
// user.ts
export interface User {}

When to Continue Using Build Step

There are scenarios where transpilation is still necessary:

For Production with Optimization:

  • Code minification
  • Tree-shaking
  • Single bundle for deploy

For Advanced Features:

  • Decorators (NestJS, TypeORM)
  • Complex paths/aliases
  • JSX/TSX (React)

Workflow Comparison

Aspect Traditional Build Node.js 25 Native
Initial setup Complex Zero
Dev time Slower Instant
Debug Source maps Direct
Hot reload Configuration Simple
Runtime performance Same Same
TS features All Most

Gradual Migration

If you have an existing project, you can migrate gradually:

// package.json
{
  "name": "my-project",
  "type": "module",
  "engines": {
    "node": ">=25.0.0"
  },
  "scripts": {
    "dev": "node --watch src/index.ts",
    "start": "node src/index.ts",
    "build": "tsc",
    "start:prod": "node dist/index.js"
  }
}
// tsconfig.json - minimal configuration
{
  "compilerOptions": {
    "target": "ES2024",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "skipLibCheck": true,
    "declaration": true,
    "outDir": "./dist"
  },
  "include": ["src/**/*"]
}

Conclusion

Node.js 25 represents a milestone in the history of JavaScript on the server. The ability to execute TypeScript natively eliminates one of the biggest frictions in modern development and brings the experience closer to languages like Deno and Bun.

For developers, this means less configuration, faster feedback during development, and a lower barrier to entry for new projects. It's the kind of change that simplifies our lives without sacrificing the advantages of static typing.

If you haven't tried it yet, I recommend creating a test project and feeling the difference. To dive deeper into TypeScript, check out our article about State of JavaScript 2025 which brings insights about TypeScript adoption in the ecosystem.

Let's go! 🦅

Comments (0)

This article has no comments yet 😢. Be the first! 🚀🦅

Add comments