Configuration Loading: From CLI Flags to NormalizedOptions
Prerequisites
- ›Completion of Article 1 (architecture overview)
- ›Familiarity with TypeScript tsconfig.json options
- ›Basic understanding of glob patterns
Configuration Loading: From CLI Flags to NormalizedOptions
Every bundler needs a configuration pipeline, and tsup's is designed around a principle: zero config should be truly zero config, but power users should be able to control everything. This article traces the full journey from the moment tsup starts looking for a config file to the point where a fully resolved NormalizedOptions object is ready for the esbuild pipeline.
Config File Discovery with joycon
tsup supports seven config file formats plus an embedded package.json key. The discovery logic lives in src/load.ts, using the joycon library for prioritized file search:
const configPath = await configJoycon.resolve({
files: configFile
? [configFile]
: [
'tsup.config.ts',
'tsup.config.cts',
'tsup.config.mts',
'tsup.config.js',
'tsup.config.cjs',
'tsup.config.mjs',
'tsup.config.json',
'package.json',
],
cwd,
stopDir: path.parse(cwd).root,
packageKey: 'tsup',
})
flowchart TD
A["Start: loadTsupConfig(cwd)"] --> B{"Custom config<br/>file specified?"}
B -->|yes| C["Search for that file only"]
B -->|no| D["Search priority list:<br/>1. tsup.config.ts<br/>2. tsup.config.cts<br/>3. tsup.config.mts<br/>4. tsup.config.js<br/>5. tsup.config.cjs<br/>6. tsup.config.mjs<br/>7. tsup.config.json<br/>8. package.json#tsup"]
C --> E{"Found?"}
D --> E
E -->|no| F["Return {} — no config"]
E -->|yes, .json| G["Parse JSON,<br/>extract .tsup key if package.json"]
E -->|yes, .ts/.js/.mts/.cjs/...| H["bundleRequire()"]
H --> I["config.mod.tsup || config.mod.default || config.mod"]
G --> J["Return { path, data }"]
I --> J
The search walks from the current directory up to the filesystem root (stopDir), stopping at the first match. TypeScript variants (.ts, .cts, .mts) are checked first — this is the right default since tsup's primary audience writes TypeScript.
When no config file is found, loadTsupConfig returns an empty object. tsup proceeds with CLI flags alone, which is how the "zero config" experience works — tsup src/index.ts needs nothing more.
The packageKey: 'tsup' parameter tells joycon that if it reaches package.json, it should look inside the tsup property. The code explicitly handles this on line 61:
if (configPath.endsWith('package.json')) {
data = data.tsup
}
Executing TypeScript Config Files with bundle-require
Here's the challenge: Node.js can't natively require() a .ts file. tsup solves this with bundle-require, which uses esbuild internally to compile the config file on-the-fly, write the result to a temporary file, and then require() that.
The key call is on lines 70-76:
const config = await bundleRequire({
filepath: configPath,
})
return {
path: configPath,
data: config.mod.tsup || config.mod.default || config.mod,
}
sequenceDiagram
participant tsup as tsup (load.ts)
participant br as bundle-require
participant esbuild as esbuild
participant node as Node.js
tsup->>br: bundleRequire({ filepath: "tsup.config.ts" })
br->>esbuild: Build tsup.config.ts → temp .js
esbuild-->>br: Compiled JavaScript
br->>node: require(tempFile)
node-->>br: module.exports
br-->>tsup: { mod: { default: ... } }
tsup->>tsup: Extract mod.tsup || mod.default || mod
The resolution chain config.mod.tsup || config.mod.default || config.mod accommodates three export styles:
export const tsup = { ... }— named exporttsupexport default { ... }— default export (the most common pattern)module.exports = { ... }— CJS-style (the whole module is the config)
Tip: This same
bundle-requireapproach is used by other tools like Vite forvite.config.ts. It's fast because esbuild compiles the config in milliseconds, but be aware that it does execute the compiled code — your config file runs at build time, which is how dynamic configuration works.
Dynamic Configuration: The Function Form of defineConfig
The defineConfig function is an identity function — it returns exactly what you pass it. Its only purpose is TypeScript IntelliSense:
export const defineConfig = (
options:
| Options
| Options[]
| ((overrideOptions: Options) => MaybePromise<Options | Options[]>),
) => options
But notice the function signature accepts three shapes: a single config object, an array of configs, or a function that receives CLI options and returns either shape. This function form is powerful — it lets you adapt your config based on how tsup was invoked:
// tsup.config.ts
export default defineConfig((overrideOptions) => ({
entry: ['src/index.ts'],
format: overrideOptions.watch ? ['esm'] : ['cjs', 'esm'],
}))
The function form is resolved inside build() on lines 176-179:
const configData =
typeof config.data === 'function'
? await config.data(_options)
: config.data
When the config returns an array, each element is processed independently via Promise.all on line 181. This is how you define multiple build targets in a single config file — for example, building both a library and a CLI tool with different entry points and formats.
flowchart TD
A["config.data"] --> B{"typeof === 'function'?"}
B -->|yes| C["config.data(_options)<br/>Pass CLI flags to function"]
B -->|no| D["Use as-is"]
C --> E{"Returns array?"}
D --> E
E -->|yes| F["Promise.all — process each config in parallel"]
E -->|no| G["Process single config"]
F --> H["normalizeOptions() × N"]
G --> H
Option Precedence and normalizeOptions()
The normalizeOptions() function transforms the loose Options type into the strict NormalizedOptions type. The layering order is straightforward — CLI flags override config file values:
const _options = {
...optionsFromConfigFile,
...optionsOverride, // CLI flags win
}
Then defaults are applied:
| Option | Default | Notes |
|---|---|---|
outDir |
'dist' |
Standard output directory |
format |
['cjs'] |
Single string is wrapped in array |
target |
'node16' |
Falls back after tsconfig check |
removeNodeProtocol |
true |
Strips node: prefix from imports |
The dts option undergoes significant normalization on lines 88-95. It starts as boolean | string | DtsConfig and becomes DtsConfig | undefined:
dts:
typeof _options.dts === 'boolean'
? _options.dts
? {} // true → empty DtsConfig (use defaults)
: undefined // false → disabled
: typeof _options.dts === 'string'
? { entry: _options.dts } // string → entry shorthand
: _options.dts, // already DtsConfig
Entry files also get normalized here. When the entry is an array of strings (e.g., ['src/**/*.ts']), it's expanded via tinyglobby's glob() on line 111. When it's a Record<string, string>, each file is validated for existence.
tsconfig Integration: Paths, Decorators, and Target
After basic defaults, normalizeOptions() loads the project's tsconfig.json using bundle-require's loadTsConfig helper. Three pieces of data are extracted, visible on lines 129-155:
const tsconfig = loadTsConfig(process.cwd(), options.tsconfig)
if (tsconfig) {
options.tsconfigResolvePaths = tsconfig.data?.compilerOptions?.paths || {}
options.tsconfigDecoratorMetadata =
tsconfig.data?.compilerOptions?.emitDecoratorMetadata
// ...
if (!options.target) {
options.target = tsconfig.data?.compilerOptions?.target?.toLowerCase()
}
}
flowchart TD
TC["tsconfig.json"] --> PATHS["compilerOptions.paths<br/>→ tsconfigResolvePaths"]
TC --> DEC["compilerOptions.emitDecoratorMetadata<br/>→ tsconfigDecoratorMetadata"]
TC --> TGT["compilerOptions.target<br/>→ target (fallback)"]
PATHS --> EXT["esbuild external plugin<br/>(resolve aliased paths)"]
DEC --> SWC["SWC esbuild plugin<br/>(emit decorator metadata)"]
TGT --> ESB["esbuild target option"]
The tsconfigResolvePaths field feeds into the external plugin (Article 3) so that path aliases like @/utils are properly resolved instead of being marked as external. The tsconfigDecoratorMetadata flag determines whether the SWC esbuild plugin is activated — esbuild doesn't support emitDecoratorMetadata, so SWC handles it as a pre-transform.
The target fallback chain is: explicit --target CLI flag → config file target → tsconfig compilerOptions.target → 'node16'. This means if your tsconfig.json already specifies es2020, tsup will use that without any additional configuration.
Tip: If you're seeing unexpected behavior with path aliases in tsup, check that your
tsconfig.jsonhaspathsconfigured. tsup reads these directly and uses them to prevent aliased imports from being externalized.
Smart Output Extensions with defaultOutExtension
The relationship between package.json's type field, the output format, and file extensions is a common source of confusion. tsup handles it automatically in defaultOutExtension:
export function defaultOutExtension({
format,
pkgType,
}: {
format: Format
pkgType?: string
}): { js: string; dts: string } {
let jsExtension = '.js'
let dtsExtension = '.d.ts'
const isModule = pkgType === 'module'
if (isModule && format === 'cjs') {
jsExtension = '.cjs'
dtsExtension = '.d.cts'
}
if (!isModule && format === 'esm') {
jsExtension = '.mjs'
dtsExtension = '.d.mts'
}
if (format === 'iife') {
jsExtension = '.global.js'
}
return { js: jsExtension, dts: dtsExtension }
}
The logic matrix:
package.json type |
Format | JS Extension | DTS Extension |
|---|---|---|---|
(none/"commonjs") |
cjs |
.js |
.d.ts |
(none/"commonjs") |
esm |
.mjs |
.d.mts |
"module" |
cjs |
.cjs |
.d.cts |
"module" |
esm |
.js |
.d.ts |
| any | iife |
.global.js |
.d.ts |
The rule is simple: .js is used for the "natural" format of the package (CJS for non-module packages, ESM for module packages). When you need the other format, an explicit extension (.mjs or .cjs) signals the module system to Node.js. IIFE always gets .global.js since it's not consumed by Node's module resolution.
Users can override this with the outExtension option, which receives the full context including format, options, and package type — but the defaults handle the vast majority of cases correctly.
What's Next
With options fully normalized, the build is ready to begin. Article 3 dives into runEsbuild() — how tsup translates NormalizedOptions into a complete esbuild configuration, the six bundled esbuild plugins, the critical write: false pattern, and how the PluginContainer orchestrates post-build transforms with source map merging.