ts-referent
v1.2.1
Published
Automated TypeScript project references for monorepos, not just between packages, but inside them.
Maintainers
Readme
ts-referent
Automated TypeScript project references for monorepos, not just between packages, but inside them.
ts-referent splits every package into isolated slices called kinds such as source, tests, cypress, storybook, CJS, or ESM, and generates the tsconfig files needed to enforce boundaries between them. Source code cannot accidentally import test utilities. Jest types do not leak into production. Cypress globals do not clash with Jest globals. These boundaries are real because TypeScript enforces them at compile time.
Quick start
Install the package:
npm install --save-dev ts-referent
# or: yarn add --dev ts-referent
# or: pnpm add --save-dev ts-referentCreate a root tsconfig.referent.ts or tsconfig.referent.js:
import { configure } from 'ts-referent';
export default configure({
baseConfig: 'tsconfig.json',
kinds: {
base: {
include: ['**/*'],
exclude: ['**/*.spec.*'],
},
tests: {
include: ['**/*.spec.*'],
references: ['base'],
types: ['jest'],
},
},
});Make sure your base tsconfig.json disables automatic type discovery so each kind controls its own type environment:
{
"compilerOptions": {
"types": []
}
}Generate package-level tsconfig files:
npx ts-referent buildFor full-workspace type-checking, generate a separate solution-style config and build that instead of your root tsconfig.json:
npx ts-referent glossary tsconfig.packages.json
tsc -b tsconfig.packages.jsonThat is the core workflow. Define kinds once, generate configs, and let TypeScript enforce the boundaries.
What you get
With the minimal setup above:
- production code cannot import test-only code
- test-only globals do not leak into source files
- each package gets multiple generated
tsconfigfiles derived from one shared configuration - project references stay aligned with your workspace packages and local boundaries
Core commands
ts-referent build
Reads your kinds configuration, scans every package in the monorepo, and generates tsconfig files for each one.
npx ts-referent buildThis should usually run on every postinstall so generated configs stay in sync with package.json dependencies:
{
"scripts": {
"postinstall": "ts-referent build"
}
}Yarn Classic note:
postinstallhas a known issue in Yarn 1.x. For Yarn 2+, use the recommended plugin from the project documentation.
ts-referent glossary <filename>
Generates a solution-style tsconfig that references all packages. Use it for global type-checking, CI, or any place where you want tsc -b to cover the whole workspace.
npx ts-referent glossary tsconfig.packages.json
tsc -b tsconfig.packages.jsonThis file does not need to be committed. Generate it on demand.
Available filters:
--filter-by-name <glob>includes only packages matching the name pattern--filter-by-folder <glob>includes only packages in matching directories
ts-referent paths <filename>
Generates paths aliases for all local packages. Extend your base config from this file to improve editor auto-imports and local package resolution.
npx ts-referent paths tsconfig.paths.jsonAvailable options:
--extends <config>specifies a config to extend from
TypeScript 5 and newer support multiple extends, so this option is often unnecessary there.
entrypointResolveris only required if you use this command.
Setup requirements
These are easy to miss, but they matter.
types: [] in the base tsconfig
This disables automatic @types discovery, which is necessary if kinds are meant to control type visibility per slice.
TypeScript 6.0 makes this the default, but setting it explicitly keeps the behavior clear.
Keep glossary in a separate file
Do not put glossary references into your root tsconfig.json. Use tsconfig.packages.json or a similar separate file.
Including workspace-wide references in the root config can cause WebStorm's TypeScript server to hang.
Include all source in the root tsconfig
Nested generated configs will narrow things further, but exposing all source to TypeScript at the root improves cross-package auto-imports.
Design principles
Generated tsconfig files are build artifacts
ts-referent generates tsconfig files at every level: in .referent, in package roots, and anywhere your kinds require them. Treat all of these files as generated output.
They should be gitignored.
.referent
# and any per-package tsconfigs generated by ts-referentGenerated configs may reference directories that do not exist yet. They may contain paths that only make sense after install. Committing them creates stale diffs, stale pointers, and confusion about what the source of truth actually is.
The intended workflow is simple: regenerate them on every postinstall.
You configure generation, not the generated files
Generated tsconfig files are disposable. Do not edit them by hand.
To change how a package's TypeScript environment behaves:
- Change
tsconfig.referent.jsortsconfig.referent.ts - Change
package.json
ts-referent reads each package's package.json and passes it into your configuration. That means you can drive kinds, compiler options, type definitions, and dependency directions from package metadata.
package.json is the input, generated tsconfig files are the output, and tsconfig.referent is the transformation between them.
export default configure({
kinds: (inheritedKinds, currentPackage) => ({
base: {
types: currentPackage.packageJson.needsNode ? ['node'] : [],
externals: currentPackage.packageJson.externals,
},
tests: {
include: ['**/*.spec.*'],
references: ['base'],
types: ['jest'],
},
}),
});Kinds
A kind is a named slice of a package, defined by include and exclude glob patterns. Each kind becomes a separate tsconfig with its own type environment and dependency rules.
Each kind can define:
includeandexcludeto decide which files belong to the slicetypesto control which@types/*packages are visiblereferencesto define which other kinds this slice may import fromcompilerOptionsfor per-kind compiler option overridesexternalsfor project references outside the normal dependency graphoutputDirectoryandfocusOnDirectoryfor publishing scenarios
References are directional. tests -> base does not imply base -> tests.
Configuration
Configuration lives in tsconfig.referent.js or tsconfig.referent.ts. Place one at any directory level and it will affect all packages below that directory.
A configuration file exports baseConfig, kinds, and optionally entrypointResolver and useBaseUrl.
Basic root configuration
CommonJS:
/** @type {import('ts-referent').ConfigurationFile} */
module.exports = {
baseConfig: 'tsconfig.json',
kinds: {
base: {
include: ['**/*'],
exclude: ['**/*.spec.*'],
},
tests: {
include: ['**/*.spec.*'],
references: ['base'],
types: ['jest'],
},
},
};TypeScript with the typed helper:
import { configure } from 'ts-referent';
export default configure({
baseConfig: require.resolve('tsconfig.json'),
kinds: {
base: {
include: ['**/*'],
exclude: ['**/*.spec.*'],
},
tests: {
include: ['**/*.spec.*'],
references: ['base'],
types: ['jest'],
},
},
});TypeScript 6+
Set useBaseUrl: false in your configuration.
baseUrl is deprecated in TypeScript 6.0 and will be removed in TypeScript 7.0. Since TypeScript 4.1, baseUrl is no longer required for paths to work. useBaseUrl defaults to true for backward compatibility.
module.exports = {
useBaseUrl: false,
baseConfig: 'tsconfig.json',
kinds: {
/* ... */
},
};Using ESM or TypeScript config files
Run ts-referent through a loader:
node -r tsm ts-referent build
# or with yarn
node -r tsm $(yarn bin ts-referent) buildExample config:
import type { EntrypointResolver, Kinds } from 'ts-referent';
export const baseConfig = 'tsconfig.json';
export const entrypointResolver: EntrypointResolver = (packageJSON, dir) => [];
export const kinds: Kinds = {
base: {
include: ['**/*'],
exclude: ['**/*.spec.*'],
},
tests: {
include: ['**/*.spec.*'],
references: ['base'],
types: ['jest'],
},
};Dynamic kinds
kinds can be a function. It receives the inherited kinds from parent configuration and the current package, so you can vary behavior per package based on package.json or other metadata.
export default configure({
kinds: ({ base, ...rest }, currentPackage) => ({
...rest,
base: {
...base,
externals: currentPackage.packageJson.externals,
types: [...(base.types || []), 'node'],
exclude: ['**/*.spec.*'],
},
tests: {
include: ['**/*.spec.*'],
references: ['base'],
},
}),
});Alter inherited kinds
Use alter in nested tsconfig.referent.ts files when you want to modify inherited kinds without redefining everything.
import { alter } from 'ts-referent';
export default alter((currentPackage) => ({
base: {
externals: currentPackage.packageJson.externals,
types: ['node'],
exclude: ['**/*.spec.*'],
},
tests: {
include: ['**/*.spec.*'],
references: ['base'],
},
}));Configuration returned from alter is merged with inherited configuration.
To remove a kind, set it to null, set enable: false, or use disableUnmatchedKinds:
export default alter(
(currentPackage) => ({
base: {
/* ... */
},
tests: {},
}),
{ disableUnmatchedKinds: true }
);Type augmentation
Extend PackageJSON if you want custom fields to be visible inside ts-referent configuration functions:
declare module 'ts-referent' {
interface PackageJSON {
externals?: ReadonlyArray<string>;
needsNode?: boolean;
}
}
export default alter((currentPackage) => ({
base: {
externals: currentPackage.packageJson.externals,
types: currentPackage.packageJson.needsNode ? ['node'] : [],
},
}));Note: project references can affect module augmentation because of how
.d.tsfiles are generated from source files. If augmentation stops working, you may need to author.d.tsfiles manually.
entrypointResolver
This is only required for ts-referent paths. It maps package exports to path aliases.
const pickExport = (entry: string | Record<string, string>) => {
if (typeof entry === 'string') return entry;
return entry['import'] || entry['require'];
};
export default configure({
baseConfig: require.resolve('tsconfig.json'),
entrypointResolver: (packageJSON, dir) => {
if (!packageJSON.exports) return [];
return Object.entries(packageJSON.exports).map(([relativeName, pointsTo]) => {
const name = relativeName.substring(2);
return [name ? `/${name}` : '', pickExport(pointsTo)];
});
},
kinds: {
/* ... */
},
});For anything beyond a flat export map, use resolve.exports.
Isolation
By default, a package's generated tsconfig references all of its kinds. Any other package referencing it can therefore see not only its source kind, but also tests, storybook, and everything else.
Two features tighten this behavior.
isolatedInDirectory
This is a per-kind setting. It places a kind's tsconfig inside a nested directory such as cypress/, __tests__/, or examples/, making it truly local.
The kind is created only if the directory exists.
Other kinds in the same package can still access it through references. Referencing an isolated directory from the workspace level is possible through relationMapper, but should be done carefully.
isolatedMode
This is a global flag in the root tsconfig.referent.js.
When enabled, each package produces two configs:
tsconfig.jsonfor the IDEtsconfig.public.jsonfor external references
Use the internal per-kind flag to exclude kinds from the public config:
internal: truekeeps a kind visible only inside the package- kinds created with
isolatedInDirectoryare private by default, but can be made public withinternal: false
Publishing packages
Use separate kinds when you need separate CJS and ESM output:
export default configure({
kinds: {
cjs: {
include: ['**/*'],
exclude: ['**/*.spec.*'],
compilerOptions: {
target: 'es5',
module: 'commonjs',
verbatimModuleSyntax: false,
},
outputDirectory: 'dist/cjs',
focusOnDirectory: 'src',
},
esm: {
include: ['**/*'],
exclude: ['**/*.spec.*'],
outputDirectory: 'dist/esm',
focusOnDirectory: 'src',
},
},
});In monorepos where only some packages are published, put those packages under a shared directory and use alter:
import { alter } from 'ts-referent';
export default alter((_, kinds) => ({
base: {
outputDirectory: 'dist/esm',
focusOnDirectory: 'src',
},
'base-cjs': {
expectExtension: true,
...kinds['base'],
outputDirectory: 'dist/cjs',
focusOnDirectory: 'src',
compilerOptions: {
target: 'es5',
module: 'commonjs',
},
},
}));IDE notes
VS Code
VS Code handles project references out of the box.
WebStorm
Enable Recompile on changes in TypeScript settings.
With project references, incremental recompilation is typically fast. Since TypeScript 3.7, build-free editing works unless disableSourceOfProjectReferenceRedirect is enabled. If you do need compiled output, tsc -b --watch works well for smaller projects.
Why this exists
TypeScript project references let you split a codebase into sub-projects with explicit dependency edges. In a monorepo, the obvious use is linking packages to each other.
But there is a second problem inside each package. A single package usually contains source code, unit tests, integration tests, storybook stories, benchmarks, and other files that all need different type environments. Putting them all in one tsconfig creates ghostly type definitions where describe and it are globally available in production code, or where Cypress globals collide with Jest globals.
ts-referent solves this by generating multiple tsconfig files per package, with directional dependency rules between them. Tests can import source. Source cannot import tests. Each slice gets exactly the type definitions it needs.
How it compares
Most tools in this space sync inter-package dependencies into tsconfig.references. ts-referent also handles the harder intra-package case by generating multiple tsconfig files per package with isolated type environments and directional boundaries.
Monorepo orchestrators
- Nx syncs project references and scaffolds a fixed split such as
tsconfig.lib.jsonandtsconfig.spec.json. That covers the common case inside the Nx ecosystem, but not arbitrary slices with custom dependency directions. - moonrepo has strong built-in support for project references and recommends
ts-referentas a complementary tool for intra-package slicing. - Turborepo generally recommends consuming raw
.tssource directly instead of leaning on project references. That avoids some configuration overhead, but it also gives up build-boundary enforcement and is not a fit for published packages. - Rush and Lerna do not provide TypeScript-aware project reference management.
Standalone reference-sync tools
Tools such as update-ts-references, workspaces-to-typescript-project-references, and set-project-references operate at the package level only. They manage one tsconfig per package and sync references from workspace dependencies.
ts-referent works at the project-slice level: multiple tsconfig files per package, derived from shared configuration that can react to each package's package.json.
See also
- One Thing Nobody Explained To You About TypeScript
- TypeScript Project References handbook
eslint-plugin-relationsfor complementary import restriction rulesupdate-ts-referencesfor inter-package reference syncing- moonrepo's guide to TypeScript project references
License
MIT
