TypeScript compilation
Fabr compiles the TypeScript and JavaScript sources of a js_package with the version of the
TypeScript compiler you pin (the TYPESCRIPT setting), using its own wrapper, fabr-tsc (installed
with fabr in @fabr-build/js-tools), which provides Yarn PnP-style package resolution and more
control over the emitted output. Fabr generates
the compiler configuration from your build file rather than reading a tsconfig.json, and you get
the same type errors, inferred types and output as tsc would produce with that configuration,
apart from the deliberate differences listed on this page.
If you’re converting an existing project, start with Coming from tsconfig.json.
If something compiles under tsc but not under fabr, or the other way round, the
summary of differences and Troubleshooting are the
places to look.
Coming from tsconfig.json
Section titled “Coming from tsconfig.json”Each setting you’d normally put in tsconfig.json either has a fixed value in fabr or comes from
your build file:
| tsconfig.json option | In fabr |
|---|---|
target, module |
The JS_TARGET setting, written <ES-version>-<module>-<environment>, for example JS_TARGET = es2022-esm;. The module part is commonjs, esm or dual (both formats in one package); the default is es6-esm. |
lib |
Follows the ES version in JS_TARGET. To declare the level your sources are written against separately, add an ES-level flag (es5, es2015 … es2023, esnext) to the target’s deps; using a newer API is then a compile error, and sources declared below es2022 keep assignment semantics for class fields whatever version is emitted. Add the dom flag for browser APIs, which adds the dom and dom.iterable libraries. |
strict and the options it enables |
On. Relax them per target with ts/… flags in deps (see below). |
esModuleInterop |
On. Add ts/no_es_module_interop for code written with import * as x of a callable CommonJS module. |
experimentalDecorators, emitDecoratorMetadata |
Off. Add ts/experimental_decorators or ts/emit_decorator_metadata (which implies the first). |
noUncheckedSideEffectImports |
On wherever the compiler supports it (TypeScript 5.6 and later). Add ts/allow_unchecked_side_effect_imports to turn it off. |
jsx, jsxImportSource |
Set automatically when a target has .tsx or .jsx sources: the automatic runtime (react-jsx, or react-jsxdev in a debug build), imported from whichever of your direct dependencies publishes ./jsx-runtime, such as react or preact. |
sourceMap |
On, with sources inlined, for BUILD_TYPE=debug (the default) and relwithdebinfo; off for release. Each map names its source by its path in your project; see Source maps. |
moduleResolution |
Chosen for your compiler version. Imports resolve as described in How imports resolve, whatever the output format. |
types, typeRoots |
Add the @types packages you need to the target’s deps. |
declaration, allowJs, skipLibCheck, resolveJsonModule, allowArbitraryExtensions |
Always on. JavaScript sources are compiled but not type-checked (checkJs is off). |
rootDir, outDir, include |
Set by fabr from the target’s srcs. |
Flags go in a target’s deps alongside its packages:
js_package app { srcs = src:**/*.ts; deps = @npm:react:19.1.0 ts/no_strict_null_checks ts/experimental_decorators dom;}A flag can also go in compile_deps, which affects only how the target is compiled. That matters
for dom, which in deps also runs the target’s tests under jsdom, and in compile_deps gives the
DOM types alone; see DOM tests.
The strictness flags each turn off one option: ts/no_strict (all of strict),
ts/allow_implicit_any, ts/allow_implicit_this, ts/no_strict_null_checks,
ts/no_strict_property_initialization, ts/no_strict_function_types,
ts/no_strict_bind_call_apply and ts/no_use_unknown_in_catch_variables.
TYPESCRIPT names the compiler package, as a pinned version or a range
(TYPESCRIPT = @npm:typescript:5.6.3;).
How fabr’s output differs from tsc
Section titled “How fabr’s output differs from tsc”The TypeScript project treats TypeScript as type annotations over JavaScript: removing the
annotations gives back the JavaScript you wrote, and making that JavaScript work where it runs (the
right import paths, the right module system) is left to you and your other tools. Fabr instead
treats your sources as something it compiles for the target named by JS_TARGET, so producing
output that runs correctly there is fabr’s job.
That leads to three kinds of difference from tsc:
- Output fixes make the compiled output correct for its target, or let an import work without
extra configuration. For example, fabr adds the
.jsextension that Node.js requires on a relative import in ES-module output, wheretscleaves the import as you wrote it. - Earlier errors report, at compile time, code that is certain to fail when it runs. “Certain”
means in every environment the output might be used in, bundlers included: a
require()call in ES-module output fails under Node.js but works in a bundler, so fabr compiles it astscdoes. Animport typestatement is removed from the output and never runs, so none of these errors apply to it. - Spec fixes follow a specification
tscimplements in the places wheretscdeparts from it, such as Node.js’s rules forexportsmaps. Where fabr keepstsc’s behaviour instead, this page says so.
Anything else that differs from tsc is a bug, and we’d like to hear about it.
Summary of differences
Section titled “Summary of differences”| Difference | Kind | Details |
|---|---|---|
| Imports resolve by the same rules for ES-module and CommonJS output | Output fix | How imports resolve |
exports maps are read as the specification requires |
Spec fix | exports maps |
| More packages’ type declarations are found | Output fix | Finding declarations |
| Imports get the file extension their target needs | Output fix | Import extensions |
| Imports of your own package are written as relative paths | Output fix | Own-package imports |
__dirname and import.meta convert between module systems |
Output fix | Module-system globals |
| Source maps name sources by their path in your project | Output fix | Source maps |
| Declaration files never contain build paths | Output fix | Declaration files |
| One package can be built in both module formats | Output fix | Dual packages |
| Unresolvable side-effect imports are errors | Earlier error | Side-effect imports |
| Imports of files that don’t exist are errors | Earlier error | Missing files |
@types packages can’t type paths a package doesn’t export |
Earlier error | Unexported paths |
| An export that names a missing file is an error | Earlier error | Missing export targets |
How imports resolve
Section titled “How imports resolve”Imports resolve as described in Module resolution: packages come
only from your target’s declared dependencies, never from a node_modules directory, and files
inside a package are found by Node.js’s require() rules, whatever the output format.
tsc: with moduleResolution: "bundler", resolves inside a package in the same way, using the
import condition. Before TypeScript 6, though, bundler is only allowed with ES-module output;
CommonJS output is limited to node10, which ignores exports maps entirely, so an import can
resolve to a file the package doesn’t make available and then fail in require() at runtime.
fabr: resolves as bundler does for every output format and compiler version, with the
conditions for the output: types, import for ES-module output or require for CommonJS output,
and module-sync.
exports maps are read as the specification requires
Section titled “exports maps are read as the specification requires”tsc: implements most of Node.js’s
exports specification, but
departs from it in three places. fabr: follows the specification in each:
- Targets are URLs. A target is resolved as a URL relative to the package, so
"./a%20b.js"names the filea b.js, and a malformed escape ("./100%.js") or an encoded/or\(%2F,%5C) isn’t a valid target.tscreads targets as literal file paths, so it looks fora%20b.jsand doesn’t find the package’s declarations. (The general case is microsoft/TypeScript#41730.) - An excluded or invalid target ends the search. When the first condition matching an import
has a
nulltarget (meaning “deliberately not exported”) or an invalid one (such as"../outside.js"), the import doesn’t resolve, wheretscmoves on to the next condition. - A wildcard must match something. A pattern key such as
"./foo*"doesn’t matchpkg/foo, because the*has to stand for at least one character, wheretsclets it match nothing.
Node.js refuses all three imports too, so code that compiles under tsc but not under fabr would
fail when Node.js loads it. Change the import to a path the package exports.
Fabr keeps tsc’s behaviour for trailing-slash “folder” keys such as
"./features/": "./src/features/". They aren’t part of the specification (Node.js removed them in
version 17), but tsc and esbuild both still honour them and older packages rely on them.
More packages’ type declarations are found
Section titled “More packages’ type declarations are found”tsc: in its modern resolution modes, finds declarations only where it expects them: it looks for
index.d.cts beside index.cjs, and when a package has an exports map it uses a types
condition inside the map but not the package’s top-level "types" field. A package that publishes
its declarations any other way is treated as untyped (error TS7016), even though it runs correctly.
fabr: when a package’s code resolves but tsc finds no declarations for it, also looks for
declarations beside the implementation under the plain .d.ts name (index.d.ts next to
index.cjs or index.mjs), and, for the package’s main entry, the types (or main) field when
its exports map has no types condition.
Library replacements and global type packages
Section titled “Library replacements and global type packages”A dependency named @typescript/lib-<name> (for example @typescript/lib-dom) replaces the
compiler’s built-in library of that name, as it would when installed in node_modules.
Global type packages (@types/node and the like) are looked up once for the whole compilation,
from your target’s own dependencies first, so two versions of the same @types package can’t both
declare the same globals.
The compiled output
Section titled “The compiled output”Imports get the file extension their target needs
Section titled “Imports get the file extension their target needs”tsc: leaves import paths as written, so import { x } from "./util" stays "./util" in the
output, and Node.js’s ES-module loader, which requires a full file name, fails to load it.
fabr: in ES-module output, rewrites each relative import to the file the compiler emitted for it:
// Sourceimport { format } from "./format";import { parse } from "./parser"; // parser/index.ts
// ES-module outputimport { format } from "./format.js";import { parse } from "./parser/index.js";Imports you already wrote with the output extension ("./format.js") are left unchanged, and so
is an import that names an existing file exactly, such as "./theme.css", even where a module of a
similar name sits beside it (theme.css.ts). A target’s rewrite_imports rules are applied at the
same step.
The same applies to a package path that an exports map publishes by pattern. With
"./src/*": "./src/*", the map turns three/src/math/MathUtils into ./src/math/MathUtils, which
doesn’t exist, because the pattern copies what you wrote and nothing adds an extension. tsc
reports the import as not found, and Node.js and bundlers fail to load it. Fabr finds the file the
import means (MathUtils.js) and writes that path into the output, in ES-module and CommonJS output
alike:
// Sourceimport { floorPowerOfTwo } from "three/src/math/MathUtils";
// Outputimport { floorPowerOfTwo } from "three/src/math/MathUtils.js";Fabr completes the path only when the completed path resolves through the same exports map to
that same file; otherwise the import stays unresolved, as it does with tsc.
Imports of your own package are written as relative paths
Section titled “Imports of your own package are written as relative paths”tsc: leaves an import of your own package by name, such as
import { pad } from "@acme/widgets/util/pad" inside @acme/widgets, as you wrote it. Node.js
resolves a package’s import of its own name only through the package’s exports map, so the
import fails when Node.js loads it if the package has no exports map, or if the map doesn’t list
that path.
fabr: writes the import as the relative path to the same file. It’s then handled like any other relative import: it gets the extension ES-module output needs, and an import of a stylesheet names the module fabr generates for it.
// Source: src/app/main.ts in @acme/widgetsimport { pad } from "@acme/widgets/util/pad";import styles from "@acme/widgets/app/Card.module.scss";
// ES-module output: app/main.jsimport { pad } from "../util/pad.js";import styles from "./Card.css.js";CommonJS output is rewritten the same way, without the added extension. Bundlers resolve the relative form exactly as they did the original, so you can keep writing whichever form you prefer.
__dirname and import.meta convert between module systems
Section titled “__dirname and import.meta convert between module systems”tsc: compiles __dirname in ES-module output, or import.meta.url in CommonJS output, without
complaint, although neither exists in that module system, so the code fails at runtime.
fabr: converts them to their equivalents in the output’s module system, so the same source works in either:
| In your source | ES-module output | CommonJS output |
|---|---|---|
__dirname |
import.meta.dirname |
unchanged |
__filename |
import.meta.filename |
unchanged |
import.meta.dirname |
unchanged | __dirname |
import.meta.filename |
unchanged | __filename |
import.meta.url |
unchanged | a file: URL built from __filename |
import.meta.main |
unchanged | require.main === module |
A variable of your own named __dirname isn’t touched, and neither is the name directly after
typeof, so guards such as typeof __dirname !== "undefined" keep working. Anything without an
exact equivalent compiles as it does with tsc, including require, module and exports in
ES-module output and import.meta.resolve in CommonJS output.
Source maps
Section titled “Source maps”A source map names each source as a fabr: URL holding the source’s path from your project’s root
(the directory with PROJECT.fabr), whichever package it is compiled into, and includes the
source’s text:
{ "file": "index.js", "sources": ["fabr:./packages/mylib/src/index.ts"], "sourcesContent": ["…"] }tsc instead writes a path relative to the map, which, for a build in fabr’s store, would point
into its build directory. With the project-relative name, a stack trace from
node --enable-source-maps names the file you edit, as in
at greet (fabr:./packages/mylib/src/index.ts:2:9), and a bundle or test run that combines
several packages’ maps keeps each source’s name. A source generated by another target is named
fabr:<target>:<file>, the reference that names it in your build file.
The bundler and the Sass compiler name their sources the same way.
Declaration files never contain build paths
Section titled “Declaration files never contain build paths”tsc: when a declaration file refers to a type from a dependency, works out an import path from
where that dependency sits on disk. Fabr’s packages live in its own store rather than
node_modules, so that path would point into fabr’s build directory, which means nothing to anyone
who installs your package.
fabr: writes the dependency’s package name instead, and refuses to produce a declaration file that still refers to a build path.
One package in both module formats
Section titled “One package in both module formats”tsc: emits one module format per compile. Shipping a package that works for both import and
require means two configurations and two output directories, renaming one set of files, and
writing an exports map by hand that sends each kind of consumer to the right one.
fabr: a module format of dual in JS_TARGET builds a js_package in both formats in one
package:
JS_TARGET = es2022-dual;
js_package mylib { srcs = src:**/*.ts;}Each source file is compiled twice, and the two sets of output sit side by side:
| CommonJS | ES modules | |
|---|---|---|
| JavaScript | index.js |
index.mjs |
| Declarations | index.d.ts |
index.d.mts |
| Source map | index.js.map |
index.mjs.map |
The generated package.json has "type": "commonjs", and an exports map that sends import to
the .mjs files and require to the .js files, each with its own declarations; see
Publishing packages.
You write the source once, in either style. What differs between the two outputs is handled for you:
- Imports between the package’s own files name the matching format.
import { x } from "./util"becomesrequire("./util.js")inindex.jsandfrom "./util.mjs"inindex.mjs. __dirname,__filenameandimport.metaare converted for each format, as described above.
This is for a package used outside your project, typically one you publish, when you want
its consumers to be able to use it as both ESM and CommonJS. a
single-format package is often enough, since CommonJS can be imported from an ES module anywhere,
and an ES module can be required on Node.js 22.12 and later. Inside a fabr project it isn’t
needed at all: a target that depends on mylib gets mylib built in its own format, whatever that
is. To build one package as dual without changing the rest of the project, ask for it where the
package is published:
sync release { @npm:mylib:${VERSION} = mylib<JS_TARGET=es2022-dual, BUILD_TYPE=release>;}Some things have one format even in a dual package:
- Command-line programs. A script in
bin/is built as CommonJS only. - Sources with a fixed format. An
.mtsfile is always an ES module and a.ctsfile always CommonJS, so each produces one output (.mjsor.cjs), and itsexportsentry has one side. - Things that aren’t packages. A
js_bundleand ajs_scriptare one program, so with adualtarget they are built as ES modules. - Tests. Tests are compiled in the format their test framework needs, CommonJS for
nodeandjestand ES modules forvitest, so a dual package’s tests exercise one format.
Errors fabr reports that tsc does not
Section titled “Errors fabr reports that tsc does not”These errors apply only to imports that run. An import type statement never runs, so none of them
apply to it.
Unresolvable side-effect imports are errors
Section titled “Unresolvable side-effect imports are errors”A side-effect import such as import "./polyfill" that can’t be resolved would fail as soon as the
module loads, so fabr reports it. tsc reports these only when noUncheckedSideEffectImports is
set (TypeScript 5.6 and later), and fabr sets it whenever the compiler supports it. Add the
ts/allow_unchecked_side_effect_imports flag to a target’s deps to turn the check off.
For a file the compiler can’t read as a module, such as a stylesheet or an image, tsc reports
every side-effect import, because it can never resolve one. Fabr checks whether the file is there
instead, with no declarations needed: the import is accepted if the file exists and is an error if
it doesn’t. This includes such files in a dependency, as long as the package makes them available.
import "./global.css"; // accepted if global.css exists; an error if it doesn'timport "some-widget/dist/theme.css"; // likewise, if the package exports itAn import that uses a value from such a file (import logo from "./logo.svg") still needs a
declaration, as it does with tsc, and your own declare module "*.svg" patterns apply as usual.
Imports of files that don’t exist are errors
Section titled “Imports of files that don’t exist are errors”A declare module pattern such as declare module "*.svg" tells tsc what any matching import
looks like, so tsc accepts the import whether or not the file exists, and before TypeScript 5.6 it
doesn’t check side-effect imports at all. Either way, an import of a missing file compiles cleanly
and then fails in the bundler or at runtime.
Fabr reports error TS79001: Cannot find file './missing.svg'. when the file certainly isn’t there:
- for a relative import with a file extension (
"./missing.svg","./theme.css"), or an import of your own package by name, which is checked as the relative path it names. An import without an extension is left totsc, because a bundler may complete the path itself; - for an import of a path inside one of your dependencies that the package doesn’t make available, or whose file is missing.
An import of a name that isn’t a package in your build isn’t checked, because patterns such as declare module "virtual:*" are how a
bundler plugin’s virtual modules are typed. A target with ts/allow_unchecked_side_effect_imports
doesn’t get this error for side-effect imports.
@types packages can’t type paths a package doesn’t export
Section titled “@types packages can’t type paths a package doesn’t export”When a package has an exports map, only the paths it lists can be imported. tsc, having failed
to resolve such a path in the package, falls back to the matching @types package and compiles the
import if that package happens to include declarations for it. The import still fails when it runs,
because it loads the package, not its types, so fabr doesn’t make that fallback:
// "some-lib" exports only "."; @types/some-lib happens to include internal.d.tsimport { helper } from "some-lib/internal"; // error: not exported by some-libImport from a path the package exports instead.
An export that names a missing file is an error
Section titled “An export that names a missing file is an error”When the first exports condition matching an import names a file that isn’t in the package,
Node.js and bundlers fail to load it rather than falling back to a later condition. Fabr reports the
import as unresolved, where tsc takes type declarations from a later condition. This is a problem
in the package itself; if a newer version doesn’t fix it, report it to the package’s maintainers.
Troubleshooting
Section titled “Troubleshooting”| Error | Likely cause | Fix |
|---|---|---|
Cannot find module 'x' or its corresponding type declarations. |
x isn’t in your target’s deps |
Add it to deps; see Module resolution |
Cannot find module 'pkg/sub' … where the file exists, or where @types/pkg declares it |
pkg’s exports map doesn’t list ./sub |
Import a path the package exports (Unexported paths) |
Cannot find module './polyfill' or its corresponding type declarations. on a side-effect import (import "./polyfill") |
The file doesn’t exist; fabr turns on noUncheckedSideEffectImports |
Fix the path, or add ts/allow_unchecked_side_effect_imports to deps (Side-effect imports) |
error TS79001: Cannot find file './x.css'. |
The imported file doesn’t exist, although a declare module pattern matches it |
Fix the path, or add the missing file (Missing files) |
Could not find a declaration file for module 'x'. (TS7016) |
The package publishes no declarations that tsc or fabr can find |
Add the matching @types package to deps, or declare the module yourself |
Type errors that tsc didn’t report with your old tsconfig.json |
Your old configuration relaxed strict or one of its options |
Add the matching ts/… flags to deps (Coming from tsconfig.json) |
No JSX runtime specified in dependencies, and is needed to compile TSX files |
A target with .tsx or .jsx sources has no dependency that publishes ./jsx-runtime |
Add react, preact or your JSX library to deps |
Multiple JSX runtimes in dependencies (…) |
More than one direct dependency publishes ./jsx-runtime |
Keep one JSX library in the target’s direct deps |