Node 24 can execute erasable TypeScript without checking its types. Build a fixture that separates runtime, syntax, module, and compiler evidence.
An AI assistant writes a TypeScript command-line tool, you run node report.ts, and the expected line appears. That green process is useful evidence, but it is narrower than it looks. Node.js 24.18.0 can remove erasable TypeScript syntax and execute the remaining JavaScript without asking whether the types are true. A declared number can hold a string, run successfully, and produce the wrong arithmetic.
The reverse mistake is possible too. A clean tsc --noEmit result does not guarantee that Node can resolve the imports or accept the file’s syntax at runtime. Compiler aliases may work while Node ignores them. A type import written as a value import may satisfy an older configuration but fail when the module loads. An enum may type-check yet sit outside Node’s default erasable subset.
This guide turns those distinctions into a package-scoped fixture for exactly Node 24.18.0 and TypeScript 5.8.3. It exercises one clean case and eight deliberate failures. Each result belongs to a named gate, so a reviewer can say HOLD for the precise reason instead of treating “it ran” or “the editor is quiet” as a release decision.
Separate the Five Gates Before Reviewing the Code
Node’s built-in TypeScript support is execution support, not a substitute for the TypeScript compiler. In the default mode, Node strips erasable syntax, does no type checking, ignores tsconfig.json, and uses its own module rules. Those facts produce five different questions:
| Gate | Question | Evidence in this fixture |
|---|---|---|
| Runtime | Did the process start, exit as expected, and emit the observed value? | Child exit status, stdout, and stderr |
| Syntax | Can Node erase this TypeScript syntax without transforming it? | Enum and parameter-property failures |
| Module | Can Node classify the file and resolve every runtime import? | TSX, extension, type-import, alias, and dependency probes |
| Static type | Does TypeScript 5.8.3 accept the program under the release options? | tsc --noEmit with strict compatibility flags |
| Business correctness | Does the result satisfy the domain rule and user intent? | Not inferred; add product-specific assertions |
A case can pass one column and fail another. The intentional type mismatch passes the runtime gate but fails static checking. The path-alias case passes the compiler gate but fails Node’s module gate. The clean example passes four technical gates, yet it still cannot prove business correctness because a generic fixture does not know whether a real invoice, report, or migration is right.
Keep the language narrow. This is a compatibility and correctness audit, not a security boundary. A passing matrix says nothing about hostile code, dependency trust, filesystem reach, credentials, network access, or operational isolation.
Define a Release Contract That Tools Can Reproduce
The fixture pins both executables instead of accepting a floating major version. Its package manifest requires Node 24.18.0, pins TypeScript to 5.8.3 without a range, and gives the temporary project "type": "module". The test refuses to run under another Node point release and verifies the compiler’s own version output before evaluating source files.
The checker uses --noEmit, so TypeScript reports diagnostics without producing JavaScript. It adds --strict, --module NodeNext, and --moduleResolution NodeNext to model the package’s Node module behavior. --allowImportingTsExtensions permits explicit .ts specifiers during a no-emit check. The two compatibility options matter most:
--erasableSyntaxOnly, introduced in TypeScript 5.8, rejects constructs that require JavaScript generation rather than simple erasure.--verbatimModuleSyntaxpreserves value imports and erases imports explicitly marked withtype, exposing a missing type-only marker during checking.
These options align the compiler with Node’s default execution subset, but they do not make Node read the configuration. The fixture passes them directly to tsc. Node receives only a source path and applies its documented runtime behavior.
Build the Two-File Audit Fixture
Create an empty directory and save the first block as package.json. The published data-fixture-file attribute identifies the filename for mechanical extraction.
{
"name": "node-typescript-boundary-fixture",
"private": true,
"type": "module",
"engines": {
"node": "24.18.0"
},
"devDependencies": {
"typescript": "5.8.3"
},
"scripts": {
"test": "node --test typescript-contract.test.mjs"
}
}
Save the next block as typescript-contract.test.mjs beside it. The harness uses only Node built-ins. It creates a unique package under the operating system’s temporary directory, writes each tiny candidate, starts the same Node executable through process.execPath, invokes the locally installed compiler through its JavaScript entry point, records evidence, and removes the synthetic package afterward.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { after, test } from 'node:test';
const here = dirname(fileURLToPath(import.meta.url));
const project = mkdtempSync(join(tmpdir(), 'node-ts-boundary-'));
const tscEntry = join(here, 'node_modules', 'typescript', 'bin', 'tsc');
const evidence = [];
function write(relativePath, source) {
const target = join(project, relativePath);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, source, 'utf8');
}
function runNode(relativePath) {
return spawnSync(process.execPath, [join(project, relativePath)], {
cwd: project,
encoding: 'utf8',
windowsHide: true,
});
}
function runTsc(args) {
return spawnSync(process.execPath, [tscEntry, ...args], {
cwd: project,
encoding: 'utf8',
windowsHide: true,
});
}
function output(result) {
return `${result.stdout}\n${result.stderr}`;
}
const checker = [
'--noEmit',
'--strict',
'--target', 'ESNext',
'--module', 'NodeNext',
'--moduleResolution', 'NodeNext',
'--allowImportingTsExtensions',
'--erasableSyntaxOnly',
'--verbatimModuleSyntax',
];
write('package.json', JSON.stringify({
name: 'synthetic-cli-under-review',
private: true,
type: 'module',
}, null, 2));
test('the audit uses the pinned Node and TypeScript versions', () => {
assert.equal(process.version, 'v24.18.0');
const version = runTsc(['--version']);
assert.equal(version.status, 0, output(version));
assert.equal(version.stdout.trim(), 'Version 5.8.3');
});
test('a type mismatch can run while tsc rejects it', () => {
write('type-mismatch.ts', `
const retries: number = '3';
console.log(JSON.stringify({ retries, next: retries + 1 }));
`);
const runtime = runNode('type-mismatch.ts');
const types = runTsc([...checker, 'type-mismatch.ts']);
assert.equal(runtime.status, 0, output(runtime));
assert.equal(runtime.stdout.trim(), '{"retries":"3","next":"31"}');
assert.notEqual(types.status, 0);
assert.match(output(types), /TS2322/);
evidence.push({ case: 'type-mismatch', runtime: 'PASS', types: 'HOLD' });
});
test('clean erasable TypeScript runs and checks', () => {
write('clean.ts', `
type Amount = { subtotal: number; tax: number };
function total(amount: Amount): number {
return amount.subtotal + amount.tax;
}
console.log(total({ subtotal: 40, tax: 2 }));
`);
const runtime = runNode('clean.ts');
const types = runTsc([...checker, 'clean.ts']);
assert.equal(runtime.status, 0, output(runtime));
assert.equal(runtime.stdout.trim(), '42');
assert.equal(types.status, 0, output(types));
evidence.push({ case: 'clean-erasable', runtime: 'PASS', types: 'PASS' });
});
test('an enum is outside the erasable-only contract', () => {
write('enum-hold.ts', `
enum Mode { Audit = 'audit' }
console.log(Mode.Audit);
`);
const runtime = runNode('enum-hold.ts');
const types = runTsc([...checker, 'enum-hold.ts']);
assert.notEqual(runtime.status, 0);
assert.match(output(runtime), /ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX/);
assert.notEqual(types.status, 0);
assert.match(output(types), /TS1294/);
evidence.push({ case: 'enum', syntax: 'HOLD' });
});
test('a parameter property is outside the erasable-only contract', () => {
write('parameter-property-hold.ts', `
class Job {
constructor(public readonly name: string) {}
}
console.log(new Job('audit').name);
`);
const runtime = runNode('parameter-property-hold.ts');
const types = runTsc([...checker, 'parameter-property-hold.ts']);
assert.notEqual(runtime.status, 0);
assert.match(output(runtime), /ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX/);
assert.notEqual(types.status, 0);
assert.match(output(types), /TS1294/);
evidence.push({ case: 'parameter-property', syntax: 'HOLD' });
});
test('Node does not execute a TSX file', () => {
write('view.tsx', `console.log('tsx-loaded');\n`);
const runtime = runNode('view.tsx');
const types = runTsc([...checker, 'view.tsx']);
assert.notEqual(runtime.status, 0);
assert.match(output(runtime), /ERR_UNKNOWN_FILE_EXTENSION/);
assert.equal(types.status, 0, output(types));
evidence.push({ case: 'tsx', module: 'HOLD', types: 'PASS' });
});
test('an extensionless relative ESM import fails', () => {
write('extensionless-helper.ts', `export const answer = 42;\n`);
write('extensionless-entry.ts', `
import { answer } from './extensionless-helper';
console.log(answer);
`);
const runtime = runNode('extensionless-entry.ts');
const types = runTsc([...checker, 'extensionless-entry.ts']);
assert.notEqual(runtime.status, 0);
assert.match(output(runtime), /ERR_MODULE_NOT_FOUND/);
assert.notEqual(types.status, 0);
assert.match(output(types), /TS2835/);
evidence.push({ case: 'extensionless-import', module: 'HOLD' });
});
test('a missing type modifier leaves a runtime value import', () => {
write('type-definition.ts', `export type Draft = { title: string };\n`);
write('missing-type-entry.ts', `
import { Draft } from './type-definition.ts';
const draft: Draft = { title: 'review' };
console.log(draft.title);
`);
const runtime = runNode('missing-type-entry.ts');
const types = runTsc([...checker, 'missing-type-entry.ts']);
assert.notEqual(runtime.status, 0);
assert.match(output(runtime), /does not provide an export named 'Draft'/);
assert.notEqual(types.status, 0);
assert.match(output(types), /TS1484/);
evidence.push({ case: 'missing-type-modifier', module: 'HOLD', types: 'HOLD' });
});
test('tsconfig paths can satisfy tsc while Node ignores the alias', () => {
write('lib/value.ts', `export const value = 42;\n`);
write('path-alias-entry.ts', `
import { value } from '@lib/value';
console.log(value);
`);
write('tsconfig.paths.json', JSON.stringify({
compilerOptions: {
noEmit: true,
strict: true,
target: 'ESNext',
module: 'NodeNext',
moduleResolution: 'NodeNext',
allowImportingTsExtensions: true,
erasableSyntaxOnly: true,
verbatimModuleSyntax: true,
baseUrl: '.',
paths: { '@lib/*': ['./lib/*.ts'] },
},
files: ['path-alias-entry.ts', 'lib/value.ts'],
}, null, 2));
const types = runTsc(['--project', 'tsconfig.paths.json']);
const runtime = runNode('path-alias-entry.ts');
assert.equal(types.status, 0, output(types));
assert.notEqual(runtime.status, 0);
assert.match(output(runtime), /ERR_MODULE_NOT_FOUND/);
evidence.push({ case: 'tsconfig-paths', types: 'PASS', module: 'HOLD' });
});
test('Node refuses to strip TypeScript below node_modules', () => {
write('node_modules/demo-ts-dependency/package.json', JSON.stringify({
name: 'demo-ts-dependency',
version: '1.0.0',
type: 'module',
exports: './index.ts',
}, null, 2));
write('node_modules/demo-ts-dependency/index.ts', `
export const dependencyValue: number = 42;
`);
write('dependency-entry.ts', `
import { dependencyValue } from 'demo-ts-dependency';
console.log(dependencyValue);
`);
const runtime = runNode('dependency-entry.ts');
const types = runTsc([...checker, 'dependency-entry.ts']);
assert.notEqual(runtime.status, 0);
assert.match(output(runtime), /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
assert.equal(types.status, 0, output(types));
evidence.push({ case: 'typescript-dependency-source', module: 'HOLD' });
});
after(() => {
console.log(JSON.stringify({
node: process.version,
typescript: '5.8.3',
decision: evidence.length === 9 ? 'REVIEW MATRIX COMPLETE' : 'HOLD',
evidence,
}, null, 2));
rmSync(project, { recursive: true, force: true });
});
Install the exact development dependency and run the contract with the pinned Node executable:
npm install --ignore-scripts
npm test
The install step is deliberately package-local. The test never asks a global tsc which version happens to be on PATH. For durable release evidence, retain the generated lockfile, install from that lock in automation, and record the platform and complete test output. If the exact Node assertion fails, stop and switch runtimes instead of weakening it.
On the pinned verification run, Node reported v24.18.0, the compiler reported Version 5.8.3, and node:test completed ten tests with ten passes and zero failures, skips, cancellations, or todos. The emitted evidence contained all nine behavior cases. Timings are machine-specific and are not part of the decision.
Read the Nine Cases as Paired Evidence
1. The type mismatch runs, but checking stops it. Node erases : number, so the runtime sees a string. The process exits zero and prints "next":"31"; TypeScript reports TS2322. This is the central counterexample: successful execution did not validate the annotation, and a plausible-looking output did not establish arithmetic correctness.
2. Clean erasable syntax passes both tools. The Amount type disappears cleanly, the runtime prints 42, and the compiler returns zero. That supports runtime, syntax, module, and static-type gates for this tiny file. It still needs a real business assertion before RELEASE.
3–4. Enum and parameter property are syntax HOLDs. Both constructs require JavaScript generation. Default Node execution reports ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. TypeScript 5.8.3 also rejects them under erasableSyntaxOnly with TS1294. The agreement is valuable: the compiler catches incompatible syntax before deployment.
5. TSX is a module-format HOLD. Node 24.18.0 does not support the .tsx extension, even when the file contains no JSX. The checker accepts this intentionally plain file, while Node reports ERR_UNKNOWN_FILE_EXTENSION. Passing types cannot make an unsupported runtime file format load.
6. An extensionless relative import is a module HOLD. ESM import specifiers need the file extension. The helper exists, but ./extensionless-helper does not resolve as ./extensionless-helper.ts. Node reports ERR_MODULE_NOT_FOUND, and the NodeNext compiler mode supplies an earlier TS2835 diagnostic.
7. A type must be imported as a type. Node determines what to erase from the source syntax; without import type, it preserves a runtime request for Draft. The exporting module has no value by that name, so loading fails. verbatimModuleSyntax makes TypeScript report TS1484 rather than silently rewriting the mistake.
8. Compiler paths do not teach Node an alias. The dedicated configuration maps @lib/* and tsc succeeds. Node ignores tsconfig.json, treats the specifier according to runtime package resolution, and cannot find it. The official TypeScript paths documentation also warns that the option does not change emitted import paths; a runtime resolver or package-native mapping remains a separate requirement.
9. Dependency source under node_modules is a HOLD. Node refuses to perform TypeScript stripping there and reports ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. The compiler can follow the synthetic package’s TypeScript export, which makes the disagreement explicit. Publish or consume executable JavaScript for dependencies instead of expecting Node to strip their installed TypeScript sources.
Do Not Repair the Wrong Gate
When a case fails, change the artifact responsible for that gate. A type mismatch needs a code or data-contract correction, not an assertion that merely accepts the string. An enum or parameter property needs conversion to erasable syntax, a build step, or an explicitly reviewed transform mode. A missing extension needs a valid runtime specifier. An alias needs a Node-understood resolution design, not confidence borrowed from the editor.
Node documents --experimental-transform-types for transforming syntax that cannot simply be erased. This fixture intentionally does not use it. Adding that flag would change the runtime contract and would not add type checking. If a project chooses transformation, create a separate pinned matrix for that mode and retain tsc --noEmit as an independent gate.
Likewise, compiling to JavaScript is a valid architecture, but it is a different artifact flow. Test the emitted entry point, source maps, package exports, and clean-install layout. Do not run source during development, emitted code in production, and call one green check proof of both.
Convert Results Into HOLD and RELEASE Language
| Observed evidence | Decision | Required next move |
|---|---|---|
Runtime passes; tsc fails | HOLD | Correct the type or boundary data, then run both gates again |
| Compiler passes; Node syntax or module loading fails | HOLD | Fix the runtime-compatible syntax, extension, import, alias, or package artifact |
| Enum or parameter property appears in direct-run source | HOLD | Use erasable syntax or approve and test a separate transformation/build contract |
| TSX is the direct Node entry | HOLD | Compile it or select a supported runtime file format |
| Installed dependency exports TypeScript source | HOLD | Consume executable JavaScript or redesign the package boundary |
| Runtime, syntax, module, and static gates pass | REVIEW | Run domain assertions and operational checks |
| All five gates pass on the pinned artifact | RELEASE | Retain the evidence and repeat it after relevant changes |
| Node, TypeScript, module mode, or package layout changes | HOLD | Re-run the complete matrix under the new contract |
The word REVIEW in the penultimate row is deliberate. A technical clean run does not know whether a billing total follows policy, a migration preserves every record, or generated copy meets editorial requirements. Add fixtures built from representative domain cases, boundary values, failure recovery, and a human-owned acceptance rule. The type system can prevent categories of mistakes; it cannot decide what the product ought to do.
AI assistance does not change these gates. It can generate candidates, suggest test cases, or summarize diagnostics, but it should not erase a failing assertion, broaden an import rule, enable a transform flag, or relabel HOLD as RELEASE without review. Keep the compiler options, runtime version, package boundary, expected errors, and business oracle outside the assistant’s unilateral control.
Retain Enough Evidence to Re-run the Decision
Archive the two source fixture files, the lockfile, raw test output, Node and TypeScript versions, operating system, package type, compiler arguments, and the candidate commit. Record whether production executes TypeScript directly or runs emitted JavaScript. A module-mode change can alter the meaning of the same .ts source, and a dependency layout change can move a file beneath node_modules.
Extend the harness around the real CLI without discarding its negative controls. Assert exit codes and structured output, invoke actual entry paths, provide malformed and boundary inputs, and test from a clean installation. Keep expected failures narrow: an exact Node error code or TypeScript diagnostic category carries more information than “the command failed.” Avoid snapshots of whole stack traces, which are noisy across paths and platforms.
The practical rule is simple: Node running a TypeScript file proves only that Node could execute that path and produce what you observed. Static types need a compiler run. Runtime syntax and imports need a Node run. Product behavior needs a domain oracle. A release decision needs all of them, named separately.
Primary Sources and Scope
- Node.js 24.18.0 TypeScript documentation defines type stripping, the lack of type checking, ignored
tsconfig.json, supported extensions, required import extensions, erasable syntax, type-only imports, path-alias behavior, and thenode_modulesrestriction. - Node.js 24.18.0 Errors documentation defines
ERR_UNKNOWN_FILE_EXTENSION,ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, andERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. - Node.js 24.18.0 test runner, strict assertions, and child-process APIs define the built-in fixture machinery.
- TypeScript 5.8 release notes introduce
erasableSyntaxOnlyand identify constructs with runtime semantics. - The official references for
erasableSyntaxOnly,noEmit,verbatimModuleSyntax,allowImportingTsExtensions, andpathsdefine the compiler contract used here.
Sources were checked on August 30, 2026. Scope is Node.js v24.18.0 default TypeScript execution and TypeScript v5.8.3 checking in a synthetic ESM package. The fixture covers runtime exit and output, erasable syntax, module resolution, static diagnostics, and the absence of a business oracle. It does not certify security, hostile-code isolation, dependency integrity, performance, another Node or TypeScript release, CommonJS behavior, transform mode, or a production application’s domain correctness.
Review Your Draft in One Workspace
Check AI-likelihood signals, revise structure and tone, and review the result before you publish.
Open AI Humanizer