diff --git a/doc/api/cli.md b/doc/api/cli.md index 27e21835fd5b27..c90f3763f3d523 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -933,6 +933,17 @@ files with no extension will be treated as WebAssembly if they begin with the WebAssembly magic number (`\0asm`); otherwise they will be treated as ES module JavaScript. +### `--experimental-transform-types` + + + +> Stability: 1.0 - Early development + +Enables the transformation of TypeScript-only syntax into JavaScript code. +Implies `--experimental-strip-types` and `--enable-source-maps`. + ### `--experimental-eventsource` + +> Stability: 1.0 - Early development + ## Enabling There are two ways to enable runtime TypeScript support in Node.js: @@ -44,13 +53,15 @@ added: v22.6.0 > Stability: 1.0 - Early development The flag [`--experimental-strip-types`][] enables Node.js to run TypeScript -files that contain only type annotations. Such files contain no TypeScript -features that require transformation, such as enums or namespaces. Node.js will -replace inline type annotations with whitespace, and no type checking is -performed. TypeScript features that depend on settings within `tsconfig.json`, +files. By default Node.js will execute only files that contain no +TypeScript features that require transformation, such as enums or namespaces. +Node.js will replace inline type annotations with whitespace, +and no type checking is performed. +To enable the transformation of such features +use the flag [`--experimental-transform-types`][]. +TypeScript features that depend on settings within `tsconfig.json`, such as paths or converting newer JavaScript syntax to older standards, are -intentionally unsupported. To get fuller TypeScript support, including support -for enums and namespaces and paths, see [Full TypeScript support][]. +intentionally unsupported. To get full TypeScript support, see [Full TypeScript support][]. The type stripping feature is designed to be lightweight. By intentionally not supporting syntaxes that require JavaScript code @@ -82,20 +93,24 @@ The `tsconfig.json` option `allowImportingTsExtensions` will allow the TypeScript compiler `tsc` to type-check files with `import` specifiers that include the `.ts` extension. -### Unsupported TypeScript features +### TypeScript features Since Node.js is only removing inline types, any TypeScript features that -involve _replacing_ TypeScript syntax with new JavaScript syntax will error. -This is by design. To run TypeScript with such features, see -[Full TypeScript support][]. +involve _replacing_ TypeScript syntax with new JavaScript syntax will error, +unless the flag [`--experimental-transform-types`][] is passed. -The most prominent unsupported features that require transformation are: +The most prominent features that require transformation are: * `Enum` -* `experimentalDecorators` * `namespaces` +* `legacy module` * parameter properties +Since Decorators are currently a [TC39 Stage 3 proposal](https://github.com/tc39/proposal-decorators) +and will soon be supported by the JavaScript engine, +they are not transformed and will result in a parser error. +This is a temporary limitation and will be resolved in the future. + In addition, Node.js does not read `tsconfig.json` files and does not support features that depend on settings within `tsconfig.json`, such as paths or converting newer JavaScript syntax into older standards. @@ -132,8 +147,9 @@ TypeScript syntax is unsupported in the REPL, STDIN input, `--print`, `--check`, ### Source maps Since inline types are replaced by whitespace, source maps are unnecessary for -correct line numbers in stack traces; and Node.js does not generate them. For -source maps support, see [Full TypeScript support][]. +correct line numbers in stack traces; and Node.js does not generate them. +When [`--experimental-transform-types`][] is enabled, source-maps +are enabled by default. ### Type stripping in dependencies @@ -145,6 +161,7 @@ a `node_modules` path. [ES Modules]: esm.md [Full TypeScript support]: #full-typescript-support [`--experimental-strip-types`]: cli.md#--experimental-strip-types +[`--experimental-transform-types`]: cli.md#--experimental-transform-types [`tsx`]: https://tsx.is/ [`verbatimModuleSyntax`]: https://www.typescriptlang.org/tsconfig/#verbatimModuleSyntax [file extensions are mandatory]: esm.md#mandatory-file-extensions diff --git a/doc/node.1 b/doc/node.1 index 0d0473670535f8..5e809896321641 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -194,6 +194,9 @@ Enable snapshot testing in the test runner. .It Fl -experimental-strip-types Enable experimental type-stripping for TypeScript files. . +.It Fl -experimental-transform-types +Enable transformation of TypeScript-only syntax into JavaScript code. +. .It Fl -experimental-eventsource Enable experimental support for the EventSource Web API. . diff --git a/lib/internal/main/eval_string.js b/lib/internal/main/eval_string.js index 395dee90ad916b..2f8604125e17b9 100644 --- a/lib/internal/main/eval_string.js +++ b/lib/internal/main/eval_string.js @@ -14,7 +14,7 @@ const { markBootstrapComplete, } = require('internal/process/pre_execution'); const { evalModuleEntryPoint, evalScript } = require('internal/process/execution'); -const { addBuiltinLibsToObject, tsParse } = require('internal/modules/helpers'); +const { addBuiltinLibsToObject, stripTypeScriptTypes } = require('internal/modules/helpers'); const { getOptionValue } = require('internal/options'); @@ -24,7 +24,7 @@ markBootstrapComplete(); const code = getOptionValue('--eval'); const source = getOptionValue('--experimental-strip-types') ? - tsParse(code) : + stripTypeScriptTypes(code) : code; const print = getOptionValue('--print'); diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 9f9e37692a5929..451b7c2195e7ad 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -1362,8 +1362,8 @@ function loadESMFromCJS(mod, filename) { if (isUnderNodeModules(filename)) { throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename); } - const { tsParse } = require('internal/modules/helpers'); - source = tsParse(source); + const { stripTypeScriptTypes } = require('internal/modules/helpers'); + source = stripTypeScriptTypes(source, filename); } const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader(); const isMain = mod[kIsMainSymbol]; @@ -1576,9 +1576,9 @@ function loadCTS(module, filename) { throw new ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING(filename); } const source = getMaybeCachedSource(module, filename); - const { tsParse } = require('internal/modules/helpers'); - const content = tsParse(source); - module._compile(content, filename, 'commonjs'); + const { stripTypeScriptTypes } = require('internal/modules/helpers'); + const code = stripTypeScriptTypes(source, filename); + module._compile(code, filename, 'commonjs'); } /** @@ -1592,8 +1592,8 @@ function loadTS(module, filename) { } // If already analyzed the source, then it will be cached. const source = getMaybeCachedSource(module, filename); - const { tsParse } = require('internal/modules/helpers'); - const content = tsParse(source); + const { stripTypeScriptTypes } = require('internal/modules/helpers'); + const content = stripTypeScriptTypes(source, filename); let format; const pkg = packageJsonReader.getNearestParentPackageJSON(filename); // Function require shouldn't be used in ES modules. @@ -1613,7 +1613,7 @@ function loadTS(module, filename) { if (Module._cache[parentPath]) { let parentSource; try { - parentSource = tsParse(fs.readFileSync(parentPath, 'utf8')); + parentSource = stripTypeScriptTypes(fs.readFileSync(parentPath, 'utf8'), parentPath); } catch { // Continue regardless of error. } diff --git a/lib/internal/modules/esm/get_format.js b/lib/internal/modules/esm/get_format.js index 6822facc9e5343..a89446df710a94 100644 --- a/lib/internal/modules/esm/get_format.js +++ b/lib/internal/modules/esm/get_format.js @@ -163,9 +163,8 @@ function getFileProtocolModuleFormat(url, context = { __proto__: null }, ignoreE // but this gets called again from `defaultLoad`/`defaultLoadSync`. let parsedSource; if (source) { - // We do the type stripping only if `source` is not falsy. - const { tsParse } = require('internal/modules/helpers'); - parsedSource = tsParse(source); + const { stripTypeScriptTypes } = require('internal/modules/helpers'); + parsedSource = stripTypeScriptTypes(source, url); } const detectedFormat = detectModuleFormat(parsedSource, url); // When source is undefined, default to module-typescript. diff --git a/lib/internal/modules/esm/translators.js b/lib/internal/modules/esm/translators.js index d931d72f5ec1e0..8f88214f558c52 100644 --- a/lib/internal/modules/esm/translators.js +++ b/lib/internal/modules/esm/translators.js @@ -38,7 +38,7 @@ const { readFileSync } = require('fs'); const { dirname, extname, isAbsolute } = require('path'); const { loadBuiltinModule, - tsParse, + stripTypeScriptTypes, stripBOM, urlToFilename, } = require('internal/modules/helpers'); @@ -309,7 +309,7 @@ translators.set('require-commonjs', (url, source, isMain) => { translators.set('require-commonjs-typescript', (url, source, isMain) => { emitExperimentalWarning('Type Stripping'); assert(cjsParse); - const code = tsParse(stringify(source)); + const code = stripTypeScriptTypes(stringify(source), url); return createCJSModuleWrap(url, code); }); @@ -526,7 +526,7 @@ translators.set('wasm', async function(url, source) { translators.set('commonjs-typescript', function(url, source) { emitExperimentalWarning('Type Stripping'); assertBufferSource(source, false, 'load'); - const code = tsParse(stringify(source)); + const code = stripTypeScriptTypes(stringify(source), url); debug(`Translating TypeScript ${url}`); return FunctionPrototypeCall(translators.get('commonjs'), this, url, code, false); }); @@ -535,7 +535,7 @@ translators.set('commonjs-typescript', function(url, source) { translators.set('module-typescript', function(url, source) { emitExperimentalWarning('Type Stripping'); assertBufferSource(source, false, 'load'); - const code = tsParse(stringify(source)); + const code = stripTypeScriptTypes(stringify(source), url); debug(`Translating TypeScript ${url}`); return FunctionPrototypeCall(translators.get('module'), this, url, code, false); }); diff --git a/lib/internal/modules/helpers.js b/lib/internal/modules/helpers.js index 4bcfa379ae9929..890d851f5bd88f 100644 --- a/lib/internal/modules/helpers.js +++ b/lib/internal/modules/helpers.js @@ -25,6 +25,7 @@ const path = require('path'); const { pathToFileURL, fileURLToPath } = require('internal/url'); const assert = require('internal/assert'); +const { Buffer } = require('buffer'); const { getOptionValue } = require('internal/options'); const { setOwnProperty } = require('internal/util'); const { inspect } = require('internal/util/inspect'); @@ -300,7 +301,21 @@ function getBuiltinModule(id) { return normalizedId ? require(normalizedId) : undefined; } +/** + * TypeScript parsing function, by default Amaro.transformSync. + * @type {Function} + */ let typeScriptParser; +/** + * The TypeScript parsing mode, either 'strip-only' or 'transform'. + * @type {string} + */ +let typeScriptParsingMode; +/** + * Whether source maps are enabled for TypeScript parsing. + * @type {boolean} + */ +let sourceMapEnabled; /** * Load the TypeScript parser. @@ -318,22 +333,44 @@ function loadTypeScriptParser(parser) { } else { const amaro = require('internal/deps/amaro/dist/index'); // Default option for Amaro is to perform Type Stripping only. - const defaultOptions = { __proto__: null, mode: 'strip-only' }; + typeScriptParsingMode = getOptionValue('--experimental-transform-types') ? 'transform' : 'strip-only'; + sourceMapEnabled = getOptionValue('--enable-source-maps'); // Curry the transformSync function with the default options. - typeScriptParser = (source) => amaro.transformSync(source, defaultOptions); + typeScriptParser = amaro.transformSync; } return typeScriptParser; } /** + * @typedef {object} TransformOutput + * @property {string} code The compiled code. + * @property {string} [map] The source maps (optional). + * * Performs type-stripping to TypeScript source code. * @param {string} source TypeScript code to parse. - * @returns {string} JavaScript code. + * @param {string} filename The filename of the source code. + * @returns {TransformOutput} The stripped TypeScript code. */ -function tsParse(source) { +function stripTypeScriptTypes(source, filename) { assert(typeof source === 'string'); const parse = loadTypeScriptParser(); - const { code } = parse(source); + const options = { + __proto__: null, + mode: typeScriptParsingMode, + sourceMap: sourceMapEnabled, + filename, + // Transform option is only applied in transform mode. + transform: { + verbatimModuleSyntax: true, + }, + }; + const { code, map } = parse(source, options); + if (map) { + // TODO(@marco-ippolito) When Buffer.transcode supports utf8 to + // base64 transformation, we should change this line. + const base64SourceMap = Buffer.from(map).toString('base64'); + return `${code}\n\n//# sourceMappingURL=data:application/json;base64,${base64SourceMap}`; + } return code; } @@ -353,7 +390,7 @@ module.exports = { loadBuiltinModule, makeRequireFunction, normalizeReferrerURL, - tsParse, + stripTypeScriptTypes, stripBOM, toRealPath, hasStartedUserCJSExecution() { diff --git a/src/node_options.cc b/src/node_options.cc index 210e3647d0b7eb..67eb0b890aa144 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -789,6 +789,13 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { "Experimental type-stripping for TypeScript files.", &EnvironmentOptions::experimental_strip_types, kAllowedInEnvvar); + AddOption("--experimental-transform-types", + "enable transformation of TypeScript-only" + "syntax into JavaScript code", + &EnvironmentOptions::experimental_transform_types, + kAllowedInEnvvar); + Implies("--experimental-transform-types", "--experimental-strip-types"); + Implies("--experimental-transform-types", "--enable-source-maps"); AddOption("--interactive", "always enter the REPL even if stdin does not appear " "to be a terminal", diff --git a/src/node_options.h b/src/node_options.h index a2ed8b1cd4d1d0..52d57610ed103a 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -239,6 +239,7 @@ class EnvironmentOptions : public Options { std::vector preload_esm_modules; bool experimental_strip_types = false; + bool experimental_transform_types = false; std::vector user_argv; diff --git a/test/es-module/test-typescript-transform.mjs b/test/es-module/test-typescript-transform.mjs new file mode 100644 index 00000000000000..85a5cf96f3cc02 --- /dev/null +++ b/test/es-module/test-typescript-transform.mjs @@ -0,0 +1,116 @@ +import { skip, spawnPromisified } from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import { match, strictEqual } from 'node:assert'; +import { test } from 'node:test'; + +if (!process.config.variables.node_use_amaro) skip('Requires Amaro'); + +test('execute a TypeScript file with transformation enabled', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-transform-types', + '--no-warnings', + fixtures.path('typescript/ts/transformation/test-enum.ts'), + ]); + + strictEqual(result.stderr, ''); + match(result.stdout, /Hello, TypeScript!/); + strictEqual(result.code, 0); +}); + +test('reconstruct error of a TypeScript file with transformation enabled and sourcemaps', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-transform-types', + '--no-warnings', + fixtures.path('typescript/ts/transformation/test-enum-stacktrace.ts'), + ]); + + match(result.stderr, /test-enum-stacktrace\.ts:4:7/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 1); +}); + +test('reconstruct error of a TypeScript file with transformation enabled without sourcemaps', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-transform-types', + '--no-enable-source-maps', + '--no-warnings', + fixtures.path('typescript/ts/transformation/test-enum-stacktrace.ts'), + ]); + match(result.stderr, /test-enum-stacktrace\.ts:5:7/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 1); +}); + +test('should not elide unused imports', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-transform-types', + '--no-warnings', + fixtures.path('typescript/ts/transformation/test-unused-import.ts'), + ]); + match(result.stderr, /ERR_UNSUPPORTED_DIR_IMPORT/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 1); +}); + +test('execute a TypeScript file with namespace', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-transform-types', + '--no-warnings', + fixtures.path('typescript/ts/transformation/test-namespace.ts'), + ]); + + strictEqual(result.stderr, ''); + match(result.stdout, /Hello, TypeScript!/); + strictEqual(result.code, 0); +}); + +// Decorators are currently ignored by transpilation +// and will be unusable until V8 adds support for them. +test('execute a TypeScript file with decorator', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-transform-types', + '--no-warnings', + fixtures.path('typescript/ts/transformation/test-decorator.ts'), + ]); + + strictEqual(result.stdout, ''); + match(result.stderr, /Invalid or unexpected token/); + strictEqual(result.code, 1); +}); + +test('execute a TypeScript file with legacy-module', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-transform-types', + '--no-warnings', + fixtures.path('typescript/ts/transformation/test-legacy-module.ts'), + ]); + + strictEqual(result.stderr, ''); + match(result.stdout, /Hello, TypeScript!/); + strictEqual(result.code, 0); +}); + +test('execute a TypeScript file with modern typescript syntax', async () => { + const result = await spawnPromisified(process.execPath, [ + '--experimental-transform-types', + '--no-warnings', + fixtures.path('typescript/ts/transformation/test-modern-typescript.ts'), + ]); + + strictEqual(result.stderr, ''); + match(result.stdout, /Hello, TypeScript!/); + strictEqual(result.code, 0); +}); + +test('execute a transpiled JavaScript file', async () => { + const result = await spawnPromisified(process.execPath, [ + '--enable-source-maps', + '--no-warnings', + fixtures.path('typescript/ts/transformation/test-transformed-typescript.js'), + ]); + + match(result.stderr, /Stacktrace at line 28/); + match(result.stderr, /test-failing-arm64\.js:28:7/); + strictEqual(result.stdout, ''); + strictEqual(result.code, 1); +}); diff --git a/test/fixtures/typescript/ts/transformation/test-decorator.ts b/test/fixtures/typescript/ts/transformation/test-decorator.ts new file mode 100644 index 00000000000000..9590ee2591398d --- /dev/null +++ b/test/fixtures/typescript/ts/transformation/test-decorator.ts @@ -0,0 +1,10 @@ +function greet(target: any, propertyKey: string, descriptor: PropertyDescriptor) { + descriptor.value = () => console.log('Hello, TypeScript!'); +} + +class Greeter { + @greet + sayHi() { } +} + +new Greeter().sayHi(); diff --git a/test/fixtures/typescript/ts/transformation/test-enum-stacktrace.ts b/test/fixtures/typescript/ts/transformation/test-enum-stacktrace.ts new file mode 100644 index 00000000000000..de6a9eda0a73e5 --- /dev/null +++ b/test/fixtures/typescript/ts/transformation/test-enum-stacktrace.ts @@ -0,0 +1,4 @@ +enum Foo { + A = "Hello, TypeScript!", +} +throw new Error(Foo.A); diff --git a/test/fixtures/typescript/ts/transformation/test-enum.ts b/test/fixtures/typescript/ts/transformation/test-enum.ts new file mode 100644 index 00000000000000..2c874cdfae7564 --- /dev/null +++ b/test/fixtures/typescript/ts/transformation/test-enum.ts @@ -0,0 +1,4 @@ +enum Foo { + A = "Hello, TypeScript!", +} +console.log(Foo.A); diff --git a/test/fixtures/typescript/ts/transformation/test-legacy-module.ts b/test/fixtures/typescript/ts/transformation/test-legacy-module.ts new file mode 100644 index 00000000000000..917adf23d82815 --- /dev/null +++ b/test/fixtures/typescript/ts/transformation/test-legacy-module.ts @@ -0,0 +1,12 @@ +module Greeter { + export interface Person { + name: string; + } + + export function greet(person: Person): string { + return `Hello, ${person.name}!`; + } +} + +const user: Greeter.Person = { name: "TypeScript" }; +console.log(Greeter.greet(user)); diff --git a/test/fixtures/typescript/ts/transformation/test-modern-typescript.ts b/test/fixtures/typescript/ts/transformation/test-modern-typescript.ts new file mode 100644 index 00000000000000..5f62bb1488bb90 --- /dev/null +++ b/test/fixtures/typescript/ts/transformation/test-modern-typescript.ts @@ -0,0 +1,12 @@ +class Foo { + foo = "Hello, TypeScript!"; +} + +class Bar extends Foo { + get foo() { + return "I'm legacy and should not be called!" + } + set foo(v) { } +} + +console.log(new Bar().foo); diff --git a/test/fixtures/typescript/ts/transformation/test-namespace.ts b/test/fixtures/typescript/ts/transformation/test-namespace.ts new file mode 100644 index 00000000000000..a69134cb6cd1c1 --- /dev/null +++ b/test/fixtures/typescript/ts/transformation/test-namespace.ts @@ -0,0 +1,7 @@ +namespace Greeting { + export function sayHello(name: string) { + return `Hello, ${name}!`; + } +} + +console.log(Greeting.sayHello("TypeScript!")); diff --git a/test/fixtures/typescript/ts/transformation/test-transformed-typescript.js b/test/fixtures/typescript/ts/transformation/test-transformed-typescript.js new file mode 100644 index 00000000000000..074a070ba3314c --- /dev/null +++ b/test/fixtures/typescript/ts/transformation/test-transformed-typescript.js @@ -0,0 +1,36 @@ +var Mathematics; +(function (Mathematics) { + let Operation; + (function (Operation) { + Operation[Operation["Add"] = 0] = "Add"; + Operation[Operation["Subtract"] = 1] = "Subtract"; + Operation[Operation["Multiply"] = 2] = "Multiply"; + Operation[Operation["Divide"] = 3] = "Divide"; + })(Operation = Mathematics.Operation || (Mathematics.Operation = {})); + class Calculator { + op; + constructor(op) { + this.op = op; + } + perform(a, b) { + switch (this.op) { + case 0: + return a + b; + case 1: + return a - b; + case 2: + return a * b; + case 3: + if (b === 0) throw new Error("Division by zero!"); + return a / b; + default: + throw new Error("Unknown operation"); + } + } + } + Mathematics.Calculator = Calculator; +})(Mathematics || (Mathematics = {})); +throw new Error("Stacktrace at line 28"); + + +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbInRlc3QtZmFpbGluZy1hcm02NC5qcyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiO1VBQ1U7O2NBQ007Ozs7O09BQUEsd0JBQUEsMEJBQUE7SUFPTCxNQUFNOztRQUNULFlBQVksQUFBUSxFQUFhLENBQUU7aUJBQWYsS0FBQTtRQUFpQjtRQUVyQyxRQUFRLENBQVMsRUFBRSxDQUFTLEVBQVU7WUFDbEMsT0FBUSxJQUFJLENBQUMsRUFBRTtnQkFDWDtvQkFBb0IsT0FBTyxJQUFJO2dCQUMvQjtvQkFBeUIsT0FBTyxJQUFJO2dCQUNwQztvQkFBeUIsT0FBTyxJQUFJO2dCQUNwQztvQkFDSSxJQUFJLE1BQU0sR0FBRyxNQUFNLElBQUksTUFBTTtvQkFDN0IsT0FBTyxJQUFJO2dCQUNmO29CQUNJLE1BQU0sSUFBSSxNQUFNO1lBQ3hCO1FBQ0o7SUFDSjtnQkFmYSxhQUFBO0FBZ0JqQixHQXhCVSxnQkFBQTtBQTBCVixNQUFNLElBQUksTUFBTSJ9 diff --git a/test/fixtures/typescript/ts/transformation/test-unused-import.ts b/test/fixtures/typescript/ts/transformation/test-unused-import.ts new file mode 100644 index 00000000000000..5390abeb96abf6 --- /dev/null +++ b/test/fixtures/typescript/ts/transformation/test-unused-import.ts @@ -0,0 +1,3 @@ +// @ts-ignore +import { missing } from "."; +export { };