Publish workspace packages to npm without rewriting your imports by hand.
tsf is a multi-target TypeScript build tool for monorepos. It compiles one source into multiple outputs — rewriting @scope/pkg workspace imports to relative paths for npm, preserving them for local dev, and bundling them for browsers. Declarations included.
You have a TypeScript monorepo. Locally, import { Thing } from '@scope/core' resolves via workspace symlinks. But when you npm publish, that import means nothing — consumers need real relative paths.
Today your options are:
- A custom build script that rewrites imports, manages declarations, and coordinates builds across packages (often hundreds of lines of bash)
- A bundler that inlines everything, losing tree-shaking for your consumers
- Manual parallel tsconfig files per output target, kept in sync by hand
tsf replaces all of that with a config file:
{
"projects": ["packages/*/tsconfig.json"],
"targets": {
"local": {
"module": "commonjs",
"outDir": "dist",
"imports": "preserve",
"declarations": true
},
"npm": {
"module": "commonjs",
"outDir": "dist-npm",
"imports": "relative",
"declarations": true,
"condition": "publish"
}
}
}
The local target keeps workspace imports intact for development. The npm target rewrites them to relative paths — only for packages that have publishConfig. One source, two outputs.
Your source code:
import { createLogger } from '@myorg/logger';
import { validate } from '@myorg/schema';
Local build (imports: "preserve") — unchanged:
const { createLogger } = require("@myorg/logger");
const { validate } = require("@myorg/schema");
npm build (imports: "relative") — rewritten:
const { createLogger } = require("../logger/dist-npm/index.js");
const { validate } = require("../schema/dist-npm/index.js");
Declarations are rewritten too. No manual work. No build script.
pnpm add -D tsf
# or
npm install -D tsf
tsf init # Generate config from existing project
tsf build # Build default target
tsf build --all # Build all targets
tsf version 1.0.0 --condition publish # Set version on npm packages
tsf publish # Publish to npm in dependency order
tsf list --condition publish # List npm packages
tsf info # Show resolved build plan
| Option | Description |
|---|---|
module |
Output module format: commonjs, esnext, es2020, es2022, node16, nodenext |
format |
Bundler format: cjs, esm, iife, umd |
outDir |
Output directory (relative to each package) |
outFile |
Single output file (alternative to outDir) |
imports |
Import resolution strategy (see below) |
declarations |
Generate .d.ts files |
condition |
Conditional target — "publish" only applies to packages with publishConfig |
transpiler |
Compiler: tsc (default), esbuild, swc |
bundler |
Bundler: esbuild, rollup (requires imports: "bundle") |
banner |
Prepend to output (e.g., "#!/usr/bin/env node" for CLI tools) |
external |
Dependencies to exclude from bundling |
Add ts-forge.json in any package directory:
{
"targets": {
"local": { "skip": true }
}
}
| Strategy | Use Case | What Happens |
|---|---|---|
preserve |
Local dev | Imports left as-is |
relative |
npm publish | @scope/pkg → relative paths |
bundle |
Browser/CLI | All imports inlined |
specifier-map |
Deno | Rewritten per import map |
Build targets across all packages in dependency order.
tsf build # Build default (unconditional) targets
tsf build --all # Build all targets
tsf build --target npm # Build specific target
tsf build --condition publish # Build targets matching condition
tsf build --all --clean # Clean output dirs first
tsf build --all --no-check # Skip type checking
tsf build --watch # Watch mode
tsf build --parallel 4 # Limit concurrency
tsf build --all --sync-package-json # Sync package.json after build
Type-check all projects without emitting.
Display the resolved build plan: packages, dependency order, and targets with per-target package counts.
Generate ts-forge.config.json by detecting existing project structure. Reads package.json, tsconfig.json, and workspace configuration. Safe to re-run — merges new targets without overwriting existing config.
Generate main, types, and exports fields in each package’s package.json from target configuration. Preserves all other fields. Publish-conditioned targets are preferred for field values.
Verify build outputs:
- Entry points declared in
package.jsonexist on disk - Declaration files (
.d.ts) exist alongside JavaScript files - No workspace specifiers (
@scope/pkg) leaked into non-preserve output
Exit code 1 if any errors found.
Set or bump version in package.json for workspace packages.
tsf version 0.9.64-beta # Set all packages to explicit version
tsf version 0.9.64-beta --condition publish # Only npm-published packages
tsf version --bump patch # Increment patch version
tsf version --bump prerelease --preid beta # Bump prerelease suffix
tsf version 0.9.64-beta --filter @scope/pkg # Specific package(s)
tsf version 0.9.64-beta --changed --condition publish # Only changed packages
tsf version 0.9.64-beta --dry-run # Preview without writing
| Option | Description |
|---|---|
|
Explicit version string (mutually exclusive with --bump) |
--bump |
Semver increment: major, minor, patch, prerelease |
--preid |
Prerelease identifier (default: beta) |
--condition |
Only packages matching target condition (e.g., publish) |
--filter |
Restrict to specific package(s), repeatable |
--changed |
Only bump packages that changed since last npm publish |
--dry-run |
Show changes without writing |
Publish workspace packages to npm in dependency order. Only publishes packages that have publishConfig in their package.json. Verifies npm login before publishing.
tsf publish # publish all publishable packages
tsf publish --dry-run # preview without publishing
tsf publish --tag beta # publish with npm dist-tag
tsf publish --filter @scope/pkg # specific package(s)
| Option | Description |
|---|---|
--tag |
npm dist-tag (default: latest) |
--condition |
Only packages matching target condition |
--filter |
Restrict to specific package(s), repeatable |
--dry-run |
Pass --dry-run to npm publish |
Show packages that have changed since their last npm publish. Compares each package against the npm registry — a package is “changed” if it has never been published, its local version differs, or it has git changes since the version tag.
tsf changed # all packages
tsf changed --condition publish # only npm-published packages
| Option | Description |
|---|---|
--condition |
Only packages matching target condition |
--filter |
Restrict to specific package(s), repeatable |
List workspace packages, one per line, in dependency order. Useful for scripting and verifying which packages match a condition.
tsf list # all packages
tsf list --condition publish # only npm-published packages
| Option | Description |
|---|---|
--condition |
Only packages matching target condition |
--filter |
Restrict to specific package(s), repeatable |
Generate .github/workflows/tsf.yml with auto-detected package manager setup and Node.js version matrix.
tsf detects pnpm, npm, and yarn workspaces. It respects pnpm-workspace.yaml exclusion patterns (e.g., !packages/forge).
Targets with condition: "publish" automatically apply only to packages that have publishConfig in their package.json. Other packages are skipped.
$ tsf info
Targets:
local: commonjs → dist, imports=preserve
npm: commonjs → dist-npm, imports=relative [condition: publish] (18 packages)
tsf caches builds based on source content, package.json version, target config, and dependency cache keys. Unchanged packages are skipped on subsequent builds. Version bumps automatically invalidate the cache. Use --clean to bypass the cache entirely. Cache is stored in .tsf-cache/ at the workspace root.
pnpm install
pnpm build
pnpm test
MIT