DEV Community

Cover image for Typescript - several sub-projects + schemas (VTS)
Stefan Werfling
Stefan Werfling

Posted on

Typescript - several sub-projects + schemas (VTS)

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)
Enter fullscreen mode Exit fullscreen mode
  • schemas - pure types, enums, interfaces and (in my case) vts validators. 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 schemas and core.
  • frontend - the browser application. Depends on schemas (and optionally core).

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

A few notes on this file:

  • The workspaces array tells npm to treat the four sub-folders as linked packages. After a single npm install in the root, every sub-project can import from 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 devDependencies are hoisted, so the same TypeScript / ESLint versions are used everywhere - no risk of "works on my machine".

Now run:

npm install
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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/**/*"
  ]
}
Enter fullscreen mode Exit fullscreen mode

The two important flags here are:

  • "composite": true - marks the package as a referenceable TypeScript project. Other packages can now list it under references in their own tsconfig.json and benefit from incremental builds.
  • "declaration": true - emits the .d.ts files that the consumers will pick up via the types field of package.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';
Enter fullscreen mode Exit fullscreen mode

…where you actually want this:

import {BehaviouralStatesResponse} from 'myproject_schemas';
Enter fullscreen mode Exit fullscreen mode

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';
// ...
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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, tsc is 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();
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Sequential - the simple compile script in the root package.json. It just cds into each folder and runs tsc. Works for small projects.
  2. Project references - run tsc -b (build mode) in the root, with a root tsconfig.json that references the sub-projects:
   {
     "files": [],
     "references": [
       { "path": "./schemas" },
       { "path": "./core" },
       { "path": "./backend" }
     ]
   }
Enter fullscreen mode Exit fullscreen mode

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 .js extension in TypeScript imports when module is set to NodeNext. The source is .ts, the import says .js. This is correct, even though it looks wrong.
  • Hoist devDependencies to the root package.json. If every sub-project pins its own TypeScript version, tsc -b will complain about "duplicate identifier" errors when the versions diverge.
  • Add dist/ to .gitignore in every sub-project, but commit the tsconfig.json and package.json. The output is regeneratable, the build config is not.
  • Keep a single .cache/ folder at the root for all .tsbuildinfo files (as in the examples above). That way you can rm -rf .cache to force a clean rebuild without touching dist/.
  • 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 * or workspace:*, 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)