TypeScript - Several Sub-projects
Introduction
Everyone has probably reached a point where a project is so large that it needs to be broken down into parts. In this article I want to show how I structure larger TypeScript projects as a monorepo with several sub-projects that share the same data types and validation logic.
The basic idea is simple: extract everything that is used in more than one place into its own package. The remaining parts of the application then depend on this shared package. With TypeScript's project references and npm workspaces, this can be done without a heavy build tool - plain tsc is enough.
Project Architecture
The structure I keep coming back to looks like this:
myproject/
├── package.json # root: defines workspaces + dev-deps
├── tsconfig.json # optional: root references
├── schemas/ # shared types, enums, validators
├── core/ # plugin system, shared runtime code
├── backend/ # server-side application
└── frontend/ # client-side application (browser)
-
schemas - pure types, enums, interfaces and (in my case)
vtsvalidators. No runtime logic, no I/O. - core - plugin contracts and runtime helpers that both backend and frontend might need.
-
backend - the Node.js server. Depends on
schemasandcore. -
frontend - the browser application. Depends on
schemas(and optionallycore).
All sub-projects share the same data shapes via schemas, which means a single change to a DTO is picked up by every consumer the next time they are compiled.
Main Project Setup
I start with an empty directory and create the root package.json:
cd myproject
nano package.json
Root package.json:
{
"name": "myproject",
"version": "1.0.0",
"description": "MyProject is ...",
"scripts": {
"compile": "cd ./schemas/ && npm run compile && cd ../core/ && npm run compile && cd ../backend/ && npm run compile"
},
"keywords": [],
"author": "Stefan Werfling",
"license": "MIT",
"workspaces": [
"schemas",
"core",
"backend",
"frontend"
],
"devDependencies": {
"@stylistic/eslint-plugin-ts": "^3.0.0",
"@types/node": "^22.15.18",
"@typescript-eslint/eslint-plugin": "^8.21.0",
"@typescript-eslint/parser": "^8.21.0",
"eslint": "^9.19.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prefer-arrow": "^1.2.3",
"npm-check-updates": "^18.0.1",
"typescript": "^5.7.3"
}
}
A few notes on this file:
- The
workspacesarray tells npm to treat the four sub-folders as linked packages. After a singlenpm installin the root, every sub-project canimportfrom every other sub-project as if it were published to a registry. - The order in which the npm packages are installed is also very important, and we must compile everything in this order: schemas → core → backend (the frontend has its own build pipeline, see below).
- The
devDependenciesare hoisted, so the same TypeScript / ESLint versions are used everywhere - no risk of "works on my machine".
Now run:
npm install
This creates the root node_modules/ with symlinks for myproject_schemas, myproject_core, etc. once those packages exist.
Schemas Sub-project
The schemas package is the foundation. It contains only types, enums, interfaces and validators - no business logic, no I/O. Anything that is needed by more than one sub-project lives here.
Directory structure:
mkdir schemas
cd schemas
mkdir src
mkdir dist
schemas/package.json:
{
"author": "Stefan Werfling",
"dependencies": {
"vts": "git+https://github.com/OpenSourcePKG/vts.git"
},
"devDependencies": {
"@stylistic/eslint-plugin-ts": "^3.0.0",
"@types/node": "^22.15.18",
"@typescript-eslint/eslint-plugin": "^8.21.0",
"@typescript-eslint/parser": "^8.21.0",
"eslint": "^9.19.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prefer-arrow": "^1.2.3",
"typescript": "^5.7.3"
},
"keywords": [],
"main": "dist/index.js",
"name": "myproject_schemas",
"scripts": {
"build": "npm run compile",
"compile": "tsc --project tsconfig.json",
"npm-check-updates": "npm-check-updates"
},
"type": "module",
"types": "dist/index.d.ts",
"version": "1.0.0",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}
}
schemas/tsconfig.json:
{
"compilerOptions": {
"composite": true,
"declaration": true,
"tsBuildInfoFile": "./../.cache/schemas.tsbuildinfo",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"module": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"removeComments": true,
"sourceMap": true,
"strict": true,
"target": "ES2022"
},
"include": [
"src/**/*"
]
}
The two important flags here are:
-
"composite": true- marks the package as a referenceable TypeScript project. Other packages can now list it underreferencesin their owntsconfig.jsonand benefit from incremental builds. -
"declaration": true- emits the.d.tsfiles that the consumers will pick up via thetypesfield ofpackage.json.
A common pitfall: import paths
If outDir, main, types and exports are not aligned, IDEs (especially WebStorm/IntelliJ) generate the wrong import path on auto-complete. You will see this:
import {BehaviouralStatesResponse} from 'myproject_schemas/dist/index.js';
…where you actually want this:
import {BehaviouralStatesResponse} from 'myproject_schemas';
The fix is to make sure all four locations point at the same file:
field in package.json
|
value |
|---|---|
main |
dist/index.js |
types |
dist/index.d.ts |
exports["."].import |
./dist/index.js |
exports["."].types |
./dist/index.d.ts |
…and that outDir in tsconfig.json is dist. Once those four match, auto-import resolves to the bare package name.
schemas/src/index.ts
Everything you want to share is re-exported from a single barrel file:
export * from './Behavioural/BehaviouralStatesResponse.js';
export * from './User/User.js';
export * from './User/UserRole.js';
// ...
Note the .js extension on the import path even though the source file is .ts - this is required by "module": "NodeNext" and saves you a lot of headache later when the package is consumed from an ESM context.
Core Sub-project
The core package sits between schemas and the application sub-projects. I use it for things that are not pure data but are still shared - plugin contracts, base classes, small utilities, error types, logging.
core/package.json:
{
"name": "myproject_core",
"version": "1.0.0",
"author": "Stefan Werfling",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "npm run compile",
"compile": "tsc --project tsconfig.json",
"npm-check-updates": "npm-check-updates"
},
"dependencies": {
"myproject_schemas": "*"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}
The "myproject_schemas": "*" dependency works because of npm workspaces: npm symlinks the local schemas/ into core/node_modules/myproject_schemas. No publishing, no npm link, no rebuild gymnastics.
core/tsconfig.json:
{
"compilerOptions": {
"composite": true,
"declaration": true,
"tsBuildInfoFile": "./../.cache/core.tsbuildinfo",
"module": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"target": "ES2022"
},
"include": [
"src/**/*"
],
"references": [
{ "path": "../schemas" }
]
}
The references block is what makes this a true project-reference build. Running tsc -b in the root walks the dependency graph and only rebuilds what actually changed.
A typical core file then looks like:
import {User, UserRole} from 'myproject_schemas';
export interface UserService {
findById(id: string): Promise<User | null>;
hasRole(user: User, role: UserRole): boolean;
}
The type comes from schemas, the interface stays in core.
Backend Sub-project
The backend is a plain Node.js application. The interesting part is that it can consume both schemas and core as if they were normal packages.
backend/package.json:
{
"name": "myproject_backend",
"version": "1.0.0",
"author": "Stefan Werfling",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "npm run compile",
"compile": "tsc --project tsconfig.json",
"start": "node dist/index.js",
"npm-check-updates": "npm-check-updates"
},
"dependencies": {
"myproject_schemas": "*",
"myproject_core": "*",
"express": "^4.21.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"typescript": "^5.7.3"
}
}
backend/tsconfig.json:
{
"compilerOptions": {
"composite": true,
"declaration": true,
"tsBuildInfoFile": "./../.cache/backend.tsbuildinfo",
"module": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"target": "ES2022"
},
"include": [
"src/**/*"
],
"references": [
{ "path": "../schemas" },
{ "path": "../core" }
]
}
And a route that uses the shared types:
import express from 'express';
import {BehaviouralStatesResponse} from 'myproject_schemas';
const app = express();
app.get('/api/behavioural/states', (_req, res) => {
const response: BehaviouralStatesResponse = {
states: [],
statusCode: 200
};
res.json(response);
});
app.listen(3000);
Because the response type is defined once in schemas, the frontend can import the exact same shape and you get end-to-end type safety without a code-generation step.
Frontend Sub-project
The frontend is where the setup deviates a little, because the browser does not understand NodeNext modules and you usually want a bundler in front of it. I use Vite here, but the same approach works with webpack or esbuild.
frontend/package.json:
{
"name": "myproject_frontend",
"version": "1.0.0",
"author": "Stefan Werfling",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"myproject_schemas": "*"
},
"devDependencies": {
"typescript": "^5.7.3",
"vite": "^5.4.0"
}
}
frontend/tsconfig.json:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"strict": true,
"jsx": "preserve",
"isolatedModules": true,
"noEmit": true
},
"include": [
"src/**/*"
],
"references": [
{ "path": "../schemas" }
]
}
Two things differ from the Node sub-projects:
-
"moduleResolution": "Bundler"- tells TypeScript that some external tool (Vite) will resolve the imports. -
"noEmit": true- the bundler emits the final JavaScript,tscis only used for type-checking.
A component then consumes the shared schema exactly like the backend does:
import {BehaviouralStatesResponse} from 'myproject_schemas';
async function loadStates(): Promise<BehaviouralStatesResponse> {
const res = await fetch('/api/behavioural/states');
return res.json();
}
If you change a field in schemas/src/Behavioural/BehaviouralStatesResponse.ts, both the Express handler and the fetch call above turn red in the IDE on the next save. That single feedback loop is, for me, the main reason for going through the monorepo setup in the first place.
Build Order and Project References
There are two ways to build everything:
-
Sequential - the simple
compilescript in the rootpackage.json. It justcds into each folder and runstsc. Works for small projects. -
Project references - run
tsc -b(build mode) in the root, with a roottsconfig.jsonthat references the sub-projects:
{
"files": [],
"references": [
{ "path": "./schemas" },
{ "path": "./core" },
{ "path": "./backend" }
]
}
tsc -b then figures out the topological order itself and only rebuilds what changed. The .tsbuildinfo files cached under .cache/ make subsequent builds nearly instant.
For CI I keep both: npm run compile for "build everything from scratch", tsc -b for the watch / incremental case during development.
Tips & Pitfalls
A short list of things that bit me along the way:
-
Always use the
.jsextension in TypeScript imports whenmoduleis set toNodeNext. The source is.ts, the import says.js. This is correct, even though it looks wrong. -
Hoist
devDependenciesto the rootpackage.json. If every sub-project pins its own TypeScript version,tsc -bwill complain about "duplicate identifier" errors when the versions diverge. -
Add
dist/to.gitignorein every sub-project, but commit thetsconfig.jsonandpackage.json. The output is regeneratable, the build config is not. -
Keep a single
.cache/folder at the root for all.tsbuildinfofiles (as in the examples above). That way you canrm -rf .cacheto force a clean rebuild without touchingdist/. -
Don't put runtime code into
schemas. The moment you do, the frontend bundle starts pulling in Node-only modules, and you spend an afternoon debugging Vite errors. Keep it types-only. -
Pin the workspace dependency to
*orworkspace:*, never to a concrete version. With npm workspaces,"myproject_schemas": "*"reliably resolves to the local symlink.
Conclusion
This setup gives you a clear separation between data definitions, shared runtime code and the actual applications, without forcing you to publish anything to a registry or run a heavy build tool. The schemas package is the contract that keeps backend and frontend in sync, and TypeScript's project references make sure that contract is enforced on every save.
If you want to see this approach in action on a real project, have a look at vtseditor - a browser-based visual editor for exactly the kind of schemas package described in this article. It lets you draw your schemas, exports them as vts-based TypeScript files, and is published as an npm CLI (npx vtseditor) so you can drop it into any monorepo without changing your existing build setup.
Happy splitting.
Top comments (0)