Native TypeScript support, built-in SQLite, and a granular permission model — all in Node.js 25+.
This guide covers everything you need to know about these three game-changing features:
from running .ts files directly without tsc to zero-config database storage
with node:sqlite and hardening applications with --permission.
Node.js 25+ brings three transformative native features to the JavaScript runtime:
native TypeScript support via built-in type stripping,
built-in SQLite through the node:sqlite module, and
the Permission Model for granular runtime security. This guide covers
all three features in depth — from enabling Node.js TypeScript native execution without
external dependencies, to using node:sqlite for zero-config database storage,
to hardening applications with --permission and process.permission.has().
| Version | Release Date | TypeScript | SQLite | Permission Model |
|---|---|---|---|---|
| v20 | Apr 2024 | — | — | Experimental (--experimental-permission) |
| v22 | Apr 2024 | Experimental type stripping | node:sqlite added (v22.5.0) |
Experimental |
| v23 | Oct 2024 | Refinements, unsupported .tsx |
— | Experimental |
| v24 | May 2025 | Feature complete | Feature complete | --permission flag (removed experimental-) |
| v25 | Oct 2025 | Stable (Stability: 2), V8 14.1 | Stable | --allow-net added |
| v26 | May 2026 | Stable, documented as "Modules: TypeScript" | Stable | Stable, config file support |
Key takeaway: As of Node.js 25+ (Current) and Node.js 24+ (LTS), all three features are stable and production-ready.
There are two ways to run TypeScript code with Node.js:
| Approach | Description | Best for |
|---|---|---|
| Built-in type stripping (recommended) | Node.js strips TypeScript syntax at runtime, keeping only JavaScript. Zero config, no external dependencies. | Simple TypeScript projects, scripts, prototypes |
Third-party full support (e.g., tsx) |
Full TypeScript support including enums, namespaces, decorators, tsconfig.json features |
Complex TypeScript projects with advanced features |
Built-in type stripping works by default — just run:
# Run a TypeScript file directly (Node.js 22+)
node app.ts
# No flags needed — type stripping is on by default in v22+
To disable type stripping:
node --no-strip-types app.ts
Node.js executes TypeScript files by replacing TypeScript-only syntax with whitespace (preserving line/column numbers in stack traces). No type checking is performed — that's still the job of tsc in your editor/CI.
Key principles:
tsconfig.json reading — Node.js ignores tsconfig.json files entirely// This code runs natively in Node.js:
function greet(name: string): string {
return `Hello, ${name}!`;
}
// The `: string` type annotations are stripped to whitespace
// Node.js sees: function greet(name) { return `Hello, ${name}!`; }
| Extension | Module System | Analogous JS Extension |
|---|---|---|
.ts | Determined by nearest package.json | .js |
.mts | Always ES module | .mjs |
.cts | Always CommonJS | .cjs |
.tsx | Unsupported | — |
Module system rules for .ts files:
"type": "module" to your package.json to use import/export syntax"type": "module", .ts files default to CommonJS (require/module.exports)// Correct:
import './helper.ts';
import { config } from './config.ts';
// Wrong — will fail:
import './helper';
import { config } from './config';
// Also correct for CommonJS:
const helper = require('./helper.ts');
| Feature | Supported? | Notes |
|---|---|---|
Type annotations (: string) | ✅ Yes | Stripped to whitespace |
| Interfaces | ✅ Yes | Stripped |
| Type aliases | ✅ Yes | Stripped |
| Generics | ✅ Yes | Stripped |
typeof, keyof, conditional types | ✅ Yes | Purely type-level |
namespace (type-only) | ✅ Yes | Only if no runtime code inside |
const assertions | ✅ Yes | Stripped |
as casts | ✅ Yes | Stripped |
| Enums | ❌ No | ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX |
| Namespaces with runtime code | ❌ No | e.g., namespace A { export let x = 1 } |
| Parameter properties | ❌ No | constructor(private x: number) |
| Decorators | ❌ No | Still Stage 3 TC39 proposal |
tsconfig.json paths | ❌ No | Use subpath imports (#) instead |
Error example when using unsupported syntax:
TypeError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript features that require
JavaScript code generation are not supported. This file contains enum declarations
which require JavaScript code generation.
type Keyword)Due to how type stripping works, the type keyword is required in imports to distinguish type-only imports from value imports:
// ✅ Correct — will work:
import type { User, Config } from './types.ts';
import { createUser, type UserInput } from './user.ts';
// ❌ Wrong — will throw runtime error:
import { User, Config } from './types.ts'; // User is a type, not a value
import { createUser, UserInput } from './user.ts'; // UserInput is a type
Enable verbatimModuleSyntax in your tsconfig.json to enforce this behavior during development.
For projects targeting Node.js 25+ with built-in type stripping:
{
"compilerOptions": {
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true
}
}
| Setting | Purpose |
|---|---|
noEmit: true | Don't output .js files — Node.js runs .ts directly |
target: "esnext" | Don't downlevel modern JS features |
module: "nodenext" | Use Node.js's module resolution |
rewriteRelativeImportExtensions: true | Allows writing .js in imports but resolving .ts |
erasableSyntaxOnly: true | Enforce only syntax Node.js can strip |
verbatimModuleSyntax: true | Require type keyword in type imports |
For projects that need enums, decorators, or other unsupported features, use a third-party loader like tsx:
# Install as a dev dependency
npm install --save-dev tsx
# Run directly
npx tsx your-file.ts
# Or use with node --import
node --import=tsx your-file.ts
Other similar libraries: ts-node, ts-blank-space, sucrase.
Dependencies under node_modules: Node.js refuses to handle TypeScript files inside node_modules. This prevents package authors from shipping TypeScript instead of compiled JavaScript.
Non-file inputs:
--eval and STDIN: Type stripping works, use --input-type to specify module system--check: Unsupportedinspect mode: UnsupportedPath aliases: tsconfig.json "paths" are not supported. Use subpath imports instead:
// package.json
{
"imports": {
"#utils/*": "./src/utils/*.ts"
}
}
// Usage:
import { formatDate } from '#utils/date.ts';
node:sqlite)node:sqliteimport { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE
);
`);
Constructor
new DatabaseSync(filename[, options])
| Parameter | Type | Default | Description |
|---|---|---|---|
filename | string | Buffer | URL | — | Path to database file or ':memory:' for in-memory |
options.openMode | number | READWRITE | CREATE | Bitwise OR of SQLITE_OPEN_* constants |
options.readonly | boolean | false | Open in read-only mode |
options.fileMustExist | boolean | false | Throw if file doesn't exist |
options.create | boolean | true | Create file if not exists |
options.bigInt | boolean | false | Return bigint for SQLite INTEGER columns |
Methods
| Method | Description |
|---|---|
db.prepare(sql) | Prepare a SQL statement → returns StatementSync |
db.exec(sql) | Execute one or more SQL statements (DDL/batch) |
db.export() | Export entire database as Uint8Array |
db.backup(targetFilename, options) | Create a backup with optional progress callback |
db.close() | Close the database connection |
db.serialize(fn) | Serialize multiple database operations |
db.transaction(fn) | Create a transaction function |
db.aggregate(name, options) | Register a custom aggregate function |
db.loadExtension(path, entryPoint?) | Load a SQLite extension |
db.enableDefensive(active) | Enable defensive mode |
Prepared statements created via db.prepare().
| Method | Description | Return Type |
|---|---|---|
stmt.run(...values) | Execute with values, returns {lastInsertRowid, changes} | object |
stmt.get(...values) | Return first matching row | object | undefined |
stmt.all(...values) | Return all matching rows | array |
stmt.iterate(...values) | Return an iterator over result set | Iterable<object> |
Usage Examples
// INSERT
const insert = db.prepare('INSERT INTO users (name, email) VALUES (?, ?)');
const result = insert.run('Alice', '[email protected]');
console.log(result.lastInsertRowid); // 1
// SELECT single row
const stmt = db.prepare('SELECT * FROM users WHERE id = ?');
const user = stmt.get(1);
console.log(user); // { id: 1, name: 'Alice', email: '[email protected]' }
// SELECT all rows
const allUsers = db.prepare('SELECT * FROM users').all();
console.log(allUsers); // [{ id: 1, ... }, { id: 2, ... }]
// Named parameters (using $, @, or : prefix)
const byEmail = db.prepare('SELECT * FROM users WHERE email = $email');
const user = byEmail.get({ $email: '[email protected]' });
| JavaScript Type | SQLite Type |
|---|---|
string | TEXT |
number | REAL or INTEGER |
bigint | INTEGER |
boolean | INTEGER (0 or 1) |
Uint8Array | BLOB |
Buffer | BLOB |
null | NULL |
Date | TEXT (ISO 8601 string) |
// Transaction (atomic, auto-rollback on error)
const insertUser = db.prepare('INSERT INTO users (name, email) VALUES (?, ?)');
const insertUsers = db.transaction((users) => {
for (const user of users) {
insertUser.run(user.name, user.email);
}
});
insertUsers([
{ name: 'Charlie', email: '[email protected]' },
{ name: 'Diana', email: '[email protected]' },
]);
// Serialization (ensure sequential access)
db.serialize(() => {
db.exec('INSERT INTO users VALUES (1, "Alice")');
db.exec('INSERT INTO users VALUES (2, "Bob")');
});
// Backup with progress
const db = new DatabaseSync('source.db');
db.backup('backup.db', {
progress: (remaining, total) => {
const pct = ((total - remaining) / total * 100).toFixed(2);
console.log(`Backup: ${pct}%`);
}
});
// Export entire database as Uint8Array
const data = db.export();
// data is a Uint8Array — write to file, send over network, etc.
import { DatabaseSync } from 'node:sqlite';
// 1. Create/open database
const db = new DatabaseSync(':memory:');
// 2. Create schema
db.exec(`
CREATE TABLE projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER REFERENCES projects(id),
title TEXT NOT NULL,
done INTEGER DEFAULT 0
);
`);
// 3. Insert with prepared statement
const insertProject = db.prepare('INSERT INTO projects (name) VALUES (?)');
const insertTask = db.prepare('INSERT INTO tasks (project_id, title) VALUES (?, ?)');
const projectResult = insertProject.run('My Project');
const projectId = projectResult.lastInsertRowid;
const addTasks = db.transaction(() => {
insertTask.run(projectId, 'Design database schema');
insertTask.run(projectId, 'Write API endpoints');
insertTask.run(projectId, 'Test the application');
});
addTasks();
// 4. Query with joins
const tasks = db.prepare(`
SELECT t.id, t.title, t.done, p.name as project
FROM tasks t
JOIN projects p ON t.project_id = p.id
WHERE p.id = ?
`).all(projectId);
console.log(tasks);
// 5. Close
db.close();
The Node.js Permission Model is a runtime security mechanism that restricts what system resources a Node.js process can access.
| Version | Milestone |
|---|---|
| v20.0.0 | Experimental with --experimental-permission |
| v24.0.0 | Flag renamed to --permission, increased stability |
| v25.0.0 | Added --allow-net |
| v26.3.0 | Stable, configuration file support, process.permission.drop() API |
Important caveat: The Permission Model implements a "least base" approach. It prevents trusted code from unintentionally accessing restricted resources, but does NOT guarantee security in the presence of malicious code. Malicious code can potentially bypass the model.
# Enable the permission model (restricts ALL permissions by default)
node --permission app.js
# Error example when accessing a restricted API:
# Error: Access to this API has been restricted
# code: 'ERR_ACCESS_DENIED',
# permission: 'FileSystemRead',
# resource: '/home/user/index.js'
When --permission is enabled, the following are restricted by default:
node:fs)process.permissionprocess.permission.has(scope[, reference]) — Check if a permission is granted:
// Check if file system write is allowed
process.permission.has('fs.write'); // true | false
// Check if specific file is writable
process.permission.has('fs.write', '/home/refs/protected-folder');
// true | false
// Check file system read
process.permission.has('fs.read'); // true | false
process.permission.has('fs.read', '/home/refs/protected-folder');
process.permission.drop(scope[, reference]) — Irreversibly drop a permission at runtime:
import fs from 'node:fs';
// Read config at startup while we still have permission
const config = fs.readFileSync('/etc/myapp/config.json', 'utf8');
// Drop read access to /etc/myapp after initialization
process.permission.drop('fs.read', '/etc/myapp');
// This will now throw ERR_ACCESS_DENIED
process.permission.has('fs.read', '/etc/myapp/config.json');
// → false
// Drop child process permission entirely (no reference = whole scope)
process.permission.drop('child');
# Allow all file system operations
node --permission --allow-fs-read=* --allow-fs-write=* index.js
# Allow specific paths
node --permission --allow-fs-read=/tmp/ --allow-fs-write=/tmp/ index.js
# Multiple paths
node --permission \
--allow-fs-read=/tmp/ \
--allow-fs-read=/home/user/.gitignore \
index.js
| Flag | Since | Description |
|---|---|---|
--allow-net | v25.0.0 | Allow network access |
--allow-child-process | v20.0.0 | Allow spawning child processes |
--allow-worker | v20.0.0 | Allow creating worker threads |
--allow-addons | v20.0.0 | Allow loading native addons |
--allow-wasi | v20.0.0 | Allow WASI access |
--allow-ffi | v20.0.0 | Allow FFI access |
node --permission \
--allow-net \
--allow-fs-read=/app \
--allow-fs-write=/app/data \
server.js
Permissions can be declared in a node.config.json file (requires --experimental-default-config-file):
{
"permission": {
"--allow-fs-read": ["/foo"],
"--allow-fs-write": ["/foo"],
"--allow-child-process": true,
"--allow-worker": true,
"--allow-net": true,
"--allow-addons": false,
"--allow-ffi": false
}
}
Limitations:
--env-file or --openssl-config are evaluated before initialization and are NOT subjected to the model.crypto, https, and related modules.libuv bypass the permission model.process._debugProcess() sends OS-level signals (SIGUSR1) to external processes and is NOT restricted by the Permission Model.| Scenario | Recommended Approach |
|---|---|
| Simple app with only type annotations, interfaces, generics | Built-in type stripping — remove tsc build step |
| Complex app with enums, decorators, custom paths, or JSX | Keep tsx or a third-party loader |
Step 1: Audit your TypeScript features — check for non-erasable syntax (enums, namespaces with runtime code, parameter properties, decorators).
Step 2: Update tsconfig.json with erasableSyntaxOnly and verbatimModuleSyntax. Run tsc --noEmit to verify compatibility.
Step 3: Fix import statements — add the type keyword where needed:
// Before:
import { User, Config, createUser } from './users.ts';
// After:
import type { User, Config } from './users.ts';
import { createUser } from './users.ts';
Step 4: Add file extensions to all imports:
// Before:
import { helper } from './utils/helper';
// After:
import { helper } from './utils/helper.ts';
Step 5: Replace tsconfig.json paths with subpath imports in package.json.
Step 6: Update package.json scripts — change "start": "node dist/index.js" to "start": "node src/index.ts".
| Pitfall | Solution |
|---|---|
| "Module not found" for type-only imports | Add type keyword: import type { ... } |
| "Cannot find module" without extension | Always include .ts extension in imports |
| Enums not working | Replace with const union types or string literal unions |
| Decorators not supported | Use wrapper functions or wait for TC39 native decorators |
tsconfig.json paths not working | Migrate to subpath imports (package.json imports field) |
.tsx files not supported | Use .ts files or a third-party loader for JSX |
node:sqlite in production?node:sqlite) is stable in Node.js 25+ and Active LTS. It provides synchronous APIs (DatabaseSync, StatementSync) that are simpler and faster than async wrappers like better-sqlite3 for most use cases. It supports full SQL, transactions, WAL mode, and backup/export — all without installing a single npm package.node:sqlite and better-sqlite3?node:sqlite is a zero-dependency native module built directly into the Node.js runtime — no npm install needed, no native compilation, and it stays in sync with the Node.js release cycle. better-sqlite3 is an external npm package with a very similar synchronous API but requires native addon compilation and manual updates. For new projects, node:sqlite is the recommended choice.--permission flag and grant specific permissions: node --permission --allow-fs-read=/data --allow-fs-write=/data app.ts. This enables Node.js TypeScript native execution (no flags needed for type stripping), gives your app database access via node:sqlite, and restricts the process to only the file paths you specify.constructor(private x)), decorators, and tsconfig.json path resolution. These require third-party tools like tsx or the TypeScript compiler. See section 2.4 for the full compatibility table..ts extension: import { config } from './config.ts' (correct) vs import { config } from './config' (fails). See section 2.3 for details.node:sqlite?node --permission --allow-fs-read=/data --allow-fs-write=/data app.ts. Without these permissions, node:sqlite operations that read or write database files will fail with ERR_ACCESS_DENIED. See section 4 for the full Permission Model API.Related Articles on maximov.by:
Official Documentation:
node:sqlite API referenceNeed to build a modern Node.js application with TypeScript, SQLite, and proper security? I'm a full-stack developer with deep experience in Node.js, TypeScript, and production architecture. Let's discuss your project.
Tell me about your project — I'll provide expert advice on architecture, technology choices, and a preliminary estimate. Free of charge.