Skip to content

feat: add deno transpile subcommand - #32691

Merged
bartlomieju merged 15 commits into
mainfrom
feature/deno-transpile
Apr 30, 2026
Merged

bartlomieju merged 15 commits into
mainfrom
feature/deno-transpile

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Mar 13, 2026 •

Copy link
Copy Markdown
Member

Add deno transpile -- a standalone subcommand for converting
TypeScript, JSX, and TSX files to JavaScript. This is a build
tool companion to deno compile and deno bundle, giving users
a first-class way to produce .js output without external tooling.

Currently marked as experimental.

Usage

# Print transpiled JS to stdout
deno transpile main.ts

# Write to a specific output file
deno transpile main.ts -o dist/main.js

# Transpile multiple files into an output directory
deno transpile src/main.ts src/helpers.ts --outdir dist

# Inline source map
deno transpile main.ts --source-map inline

# Separate .map file alongside the JS output
deno transpile main.ts -o out.js --source-map separate

# Generate .d.ts declaration files
deno transpile main.ts -o out.js --declaration

Input/output extension mapping

Input Output Source map
.ts .js .js.map
.tsx .js .js.map
.jsx .js .js.map
.mts .mjs .mjs.map
.cts .cjs .cjs.map

Features

  • Output to stdout, single file (-o), or directory (--outdir)
  • Inline and separate source maps via --source-map
  • .d.ts declaration generation via --declaration
  • Respects compilerOptions from deno.json for JSX
    transform, decorators, etc.
  • Source map file field correctly references the output
    filename, not the input

Declaration generation uses a new
TypeChecker::emit_declarations method that encapsulates the
tsc interaction, keeping the tool code clean.

Changes

  • cli/args/flags.rs: TranspileFlags, SourceMapMode, clap
    subcommand definition
  • cli/tools/transpile.rs: transpilation via deno_ast,
    declaration generation, output path computation, source map
    handling
  • cli/tsc/js.rs, cli/tsc/mod.rs: extend op_emit to
    capture .d.ts files, add TypeChecker::emit_declarations
  • cli/tsc/99_main_compiler.js: call program.emit() when
    declaration/emitDeclarationOnly is set
  • cli/factory.rs, cli/lib.rs, cli/tools/mod.rs: wiring
  • tests/specs/transpile/: 18 spec tests

bartlomieju and others added 3 commits March 13, 2026 16:41
Add a new `deno transpile` subcommand that transpiles TypeScript/JSX/TSX
files to JavaScript using deno_ast. Supports single and multiple file
transpilation with configurable source map output (none/inline/separate).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
When `--declaration` is passed, invoke TSC with `emitDeclarationOnly`
to generate .d.ts type declaration files alongside the transpiled JS.

Changes:
- Extend TSC `op_emit` to capture .d.ts files instead of panicking
- Add `emitted_files` field to TSC Response to propagate declaration output
- Call `program.emit()` in the JS compiler when declaration mode is active
- Build module graph and invoke TSC from the transpile tool

Co-Authored-By: Claude Opus 4.6 <[email protected]>
9 test cases covering:
- Basic transpile to stdout
- Output to file with -o flag
- Multiple files with --outdir
- Inline and separate source map modes
- .d.ts declaration generation with --declaration
- Error on -o with multiple files
- Skipping non-emittable .js files
- TSX transpilation

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Comment thread cli/args/flags.rs
Ok(())
}

fn transpile_parse(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arg parsing tests?

Comment thread cli/tools/transpile.rs Outdated
Comment thread cli/tools/transpile.rs Outdated
Comment thread cli/tools/transpile.rs Outdated
Comment thread cli/tools/transpile.rs Outdated
Comment thread cli/tools/transpile.rs Outdated
Comment thread cli/tools/transpile.rs Outdated
Comment thread cli/tools/transpile.rs Outdated
Comment thread cli/tools/transpile.rs Outdated
Comment thread cli/tools/transpile.rs Outdated
bartlomieju and others added 6 commits March 13, 2026 18:27
- Add arg parsing unit tests for transpile subcommand
- Create CliFactory at top of transpile() and use it throughout
- Use sys.with_paths_in_errors() for filesystem ops with better error context
- Add create_request_npm_state() factory method to reduce duplication
- Use deno_path_util::url_to_file_path instead of specifier.to_file_path()
- Add error context to all filesystem operations and compute_output_path

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Preserve relative directory structure in --outdir instead of flattening
- Fix outdir spec test to work on Windows (backslash path separators)
- Remove unnecessary canonicalize_path call
- Clarify --declaration behavior in help text

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Make --output and --outdir mutually exclusive via clap conflicts_with
- Error when --declaration used without -o/--outdir
- Make type-check errors from --declaration hard errors (exit code 1)
- Map .mts->.mjs, .cts->.cjs instead of always .js
- Error on --source-map separate when outputting to stdout
- Gate .d.ts capture in op_emit_inner behind capture_emitted_files flag
- Use BTreeMap for emitted_files for deterministic output order
- Error when all input files are skipped (nothing transpiled)
- Fix strip_prefix(cwd) producing bad paths for files outside cwd
- Add tests for new error cases

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@bartlomieju bartlomieju added this to the 2.8.0 milestone Apr 9, 2026
- Use deno.json compilerOptions for transpilation (JSX, decorators, etc.)
  instead of hardcoded defaults
- Fix .d.ts placement with -o flag: place next to output file, not source
- Use TranspileResult::into_source() instead of redundant match arms
- Use relative filename for source map file field instead of file:// URL
- Add experimental warning matching deno bundle pattern
- Add spec tests for --declaration --outdir and --declaration -o subdir
- Extract long inline test expectations to .out files

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Picked this up after @dsherret's CHANGES_REQUESTED. The arg-parse tests have been added and most of the path-handling/error-context items addressed; the open architectural item (use TypeChecker rather than calling tsc::exec directly from a tool) is still pending and dsherret offered to refactor — defer to him and you on that.

A handful of substance bugs I caught while reading the new code:

Bugs

Source-map filename uses with_extension("js.map") — wrong for .cts/.mts

let map_path = output_path.with_extension("js.map");

Path::with_extension replaces only the last extension component:

  • out.js → out.js.map ✓
  • helpers.cjs → helpers.js.map ✗ (should be helpers.cjs.map, and overwrites whatever else might be there)
  • helpers.mjs → helpers.js.map ✗

Also affects the source-map ↔ output-file linkage, since the emitted .js/.cjs/.mjs will reference helpers.js.map (which doesn't exist). Pre-existing extension should be appended: output_path.with_extension(format!("{ext}.map")) where ext is what js_extension_for_media_type returned for the input's media type.

The --outdir test only uses .ts inputs (main.ts / helpers.ts), so .cts/.mts inputs aren't covered anywhere — would catch this.

EmitOptions::source_map_file set to the input basename

source_map_file: Some(
  file_path.file_name().unwrap_or_default().to_string_lossy().into_owned()
),

source_map_file controls the file field in the emitted source map JSON, which per the spec should be the generated file (the .js output), not the original .ts input. As written, the source map says it describes main.ts, but it actually describes main.js — debuggers and other consumers will key off the wrong filename. Use output_path.file_name() (or js_filename) instead.

source_map: source_map_option clobbers compilerOptions.sourceMap from deno.json

let emit_options = EmitOptions {
  source_map: source_map_option,
  source_map_file: Some(...),
  ..transpile_and_emit_options.emit.clone()
};

source_map_option is SourceMapOption::None by default (when --source-map isn't passed). That always overrides whatever the user set in deno.json compilerOptions.sourceMap / inlineSourceMap. So deno transpile main.ts silently ignores deno.json's source-map config. If the intent is "CLI flag wins when present, otherwise fall through to deno.json," the merge should be conditional:

let emit_options = if matches!(transpile_flags.source_map, SourceMapMode::None) {
    transpile_and_emit_options.emit.clone()  // honor deno.json
} else {
    EmitOptions { source_map: source_map_option, ..transpile_and_emit_options.emit.clone() }
};

(Or document explicitly that --source-map is required to enable maps from this CLI.)

emit_declarations uses only root_names[0]'s compiler options for all files

let first_specifier = &root_names[0].0;
let base_compiler_options = compiler_options_resolver
  .for_specifier(first_specifier)
  .compiler_options_for_lib(...)?;

If transpile a.ts b.ts is invoked across two folders with different deno.json scopes, only a.ts's compiler options are used to type-check both files. The non-declaration loop already does the right thing (for_specifier per-file). For declarations, since the whole graph is type-checked under one TSC pass, you'd at minimum want to error out if the root_names span multiple compiler-options scopes — silent application of the first scope's options across the rest is the kind of bug that surfaces only in monorepos.

Smaller nits

  • tests/specs/transpile/__test__.jsonc doesn't cover .mts/.cts input — would have caught the .js.map extension bug.
  • tests/specs/transpile/declaration_emit.out doesn't constrain the order of Emit out.js vs Emit main.d.ts. The current code emits them in two separate phases (JS first, then declarations), but consider asserting the order to lock in the user-facing console output.
  • tests/specs/transpile/error_separate_sourcemap_stdout.out — the error wording "Cannot use --source-map separate when outputting to stdout. Use --output or --outdir, or use --source-map inline instead." is a clear actionable message; nice.
  • Declaration code path captures .d.ts.map files in op_emit_inner (name.ends_with(".d.ts.map")) but emitDeclarationOnly: true plus no declarationMap: true means TSC won't emit .d.ts.map. Capture is harmless but unused. Set declarationMap if you want to pass through the user's setting.
  • let response = tsc::exec(...) in emit_declarations ignores cli_options.type_check_mode() and forces TypeCheckMode::All. For --declaration this is correct (declarations require type-checking) but worth a comment so future readers don't get confused by the apparent flag override.

Architectural

dsherret already flagged that calling tsc::exec directly from a tool is too low-level. This revision exposes TypeChecker::create_request_npm_state as a partial fix — but the bulk of the request setup (config merging via JSON Value mutation, hash_data construction, root_names assembly) is still inline in transpile.rs. If dsherret takes the refactor he offered (move declaration emission into TypeChecker), most of the boilerplate in emit_declarations collapses, and the capture_emitted_files plumbing stops being a Request field — it becomes the difference between two TypeChecker methods.

I'd hold this PR for that refactor before merging — the current shape locks in tsc as a public-ish surface from cli/tools/.

CI

Fully green on the visible jobs. COMMENT rather than approval given the open dsherret thread + the source-map bugs above.

- Fix source map filename for .cts/.mts: use actual output extension
  (e.g. .mjs.map, .cjs.map) instead of always .js.map
- Fix source_map_file to reference the output file (e.g. out.js)
  instead of the input file (e.g. main.ts)
- Validate that all files share the same compiler options scope
  when generating declarations
- Add comment explaining TypeCheckMode::All for declaration emit
- Add .mts/.cts test coverage for transpilation and source maps
Move the low-level tsc::exec setup for declaration emission into a
TypeChecker::emit_declarations() method. This encapsulates compiler
options merging, hash computation, JSX config resolution, and npm
state setup, so transpile.rs only calls one high-level method.

Addresses dsherret's review feedback about tsc::exec being too
low-level to call from a tool.

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every substantive item from my prior review is fixed in the new commits, and dsherret's architectural concern (calling tsc::exec directly from a tool) is fully addressed.

Fixes for my prior bugs

Source-map filename now uses output basename

// Compute the output path/filename so source_map_file references the
// generated file (e.g. "out.js"), not the input (e.g. "main.ts").
let maybe_output_path = if !is_stdout_mode { Some(...) } else { None };
let source_map_filename = if let Some(output_path) = &maybe_output_path {
  output_path.file_name().unwrap_or_default().to_string_lossy().into_owned()
} else {
  // In stdout mode, use the input-derived JS filename
  let ext = js_extension_for_media_type(*media_type);
  file_path.with_extension(ext).file_name().unwrap_or_default().to_string_lossy().into_owned()
};

The existing check_map.ts now asserts out.js (was previously asserting just version 3). ✓

with_extension("js.map") ↔ .cts/.mts fixed

let ext = output_path.extension().unwrap_or_default().to_string_lossy();
let map_path = output_path.with_extension(format!("{ext}.map"));

So out.cjs → out.cjs.map, out.mjs → out.mjs.map, out.js → out.js.map. ✓ Plus new test fixtures cts_source_map_separate and mts_source_map_separate lock this in:

"args": "transpile helper.cts -o out.cjs --source-map separate",
"output": "[WILDCARD]Emit [WILDCARD]out.cjs\nEmit [WILDCARD]out.cjs.map\n"

And check_cts_map.ts / check_mts_map.ts verify the source map's file field references the correct output filename. ✓

emit_declarations multi-scope check

Now bails with a clear error rather than silently applying the first specifier's options:

if key != first_key {
  anyhow::bail!(
    "Cannot generate declarations for files with different compiler option scopes.\n\
     '{}' uses scope '{}' but '{}' uses scope '{}'.\n\
     Separate these files into different invocations.",
    ...
  );
}

✓ Better than my suggestion (an error message that tells the user what to do), not just an internal-state validity check.

Architectural fix — TypeChecker::emit_declarations

The boilerplate that was inline in transpile.rs (config merging via JSON Value mutation, hash_data construction, tsc::exec call) has moved into a new TypeChecker::emit_declarations method:

pub fn emit_declarations(
  &self,
  graph: Arc<ModuleGraph>,
  root_names: Vec<(ModuleSpecifier, MediaType)>,
  lib: TsTypeLib,
) -> Result<EmitDeclarationsResult, CheckError>

transpile.rs now calls type_checker.emit_declarations(graph, root_names, lib)? and consumes a clean EmitDeclarationsResult { diagnostics, emitted_files }. The 60+ lines of inline TSC plumbing collapse to a single call. tsc::exec is no longer reachable from cli/tools/transpile.rs. ✓

This is exactly the shape dsherret asked for in the original CHANGES_REQUESTED. The new method's // Declaration emit requires full type-checking regardless of the user's type_check_mode setting. comment also addresses my non-blocking note about why TypeCheckMode::All is forced.

Smaller items

Still open from prior review

  • declaration_emit.out still doesn't constrain the order of Emit out.js vs Emit main.d.ts. Cosmetic — won't block.
  • .d.ts.map capture in op_emit_inner still unused (no declarationMap: true set). Cosmetic.

New things worth a look

  • The source_map_filename computation logic now lives inline in two branches (stdout vs file mode). For stdout, it computes the would-be output filename from the input — slightly awkward since stdout mode never writes a file. The file field in the inline-source-map JSON would still claim main.js for transpile main.ts --source-map inline. That's actually correct behavior (the inline source map describes the JS that's about to land at stdout, which would conventionally be saved as main.js), so the asymmetric handling is fine. Just noting since I had to think about it.

CI

134 pass, 2 skipping, 0 fail. ✓

Verdict

Substance is now clean and complete. dsherret hasn't re-reviewed since the architectural refactor (182c056) — his original CHANGES_REQUESTED was on the inline tsc::exec shape, which is now fully resolved by the new TypeChecker::emit_declarations method. Worth pinging him for re-review since this is exactly the shape he offered to do, and he should be the one to confirm the new TypeChecker surface is what he had in mind.

Holding to COMMENT rather than approving — deno transpile is a new public CLI subcommand and the architectural refactor is in dsherret's domain, so his sign-off is the gate. From a substance-correctness standpoint, the previously-flagged bugs are all gone and CI is green.

@bartlomieju bartlomieju changed the title feat(cli): add deno transpile subcommand feat: add deno transpile subcommand Apr 30, 2026
@bartlomieju
bartlomieju merged commit 89a21c5 into main Apr 30, 2026
140 checks passed
@bartlomieju
bartlomieju deleted the feature/deno-transpile branch April 30, 2026 18:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants