Skip to content
曾亮懿 ZengLiangYi Blog

TypeScript monorepo configuration: types and runtime boundaries

Check ChatCrystal’s shared types, server, Vite client, and Electron settings, then reproduce an alias failure that passes compilation but fails at runtime.

曾亮懿

曾亮懿

4 min read
Table of contents Show Hide

TypeScript files within one repository can run in different environments: a Node server, a Vite browser build, and an Electron main process. Shared types do not imply shared compiler options. This article checks ChatCrystal’s actual inheritance, output paths, and module resolution, then reproduces an alias boundary in a temporary project.

Map the actual configuration relationships

The current base configuration sets ES2022, ESNext modules, bundler resolution, strict, isolatedModules, and other options. Only configurations extending it directly or indirectly inherit them. A root base file does not establish that every child project uses it.

Scope Actual relationship Main execution boundary
shared Extends base; composite is true Package entry points at types/index.ts, sharing type contracts
server Extends base; rootDir is its parent directory tsc emits JS executed by Node in production
client Root references app and node; neither extends base app targets the browser and node checks Vite configuration; both use noEmit
electron Independent configuration; module and resolution are Node16 Main-process output enters the packaging workflow

The root package.json lists shared, server, and client workspaces; electron is absent from that list. Client project references point to app and node rather than the entire shared project. Setting shared’s composite does not automatically form a project-reference build graph for every consumer.

Separate development execution from production output

server/tsconfig.json includes src and the parent’s shared/types. Consequently rootDir is the parent directory and outDir is server/dist, producing paths such as dist/server/src rather than simply moving src into dist.

The server scripts use tsx watch in development. Production builds clean dist, run tsc, then copy seed data; the start entry is dist/server/src/index.js. Running tsc alone does not replace the complete build command. Do not assume the type compiler always places runtime JSON and other resources in their required locations.

The server package declares type:module. Base module:ESNext controls emitted syntax, while package type and Node resolution also determine execution. Validate production output even when tsx can load a path during development; a working development server is insufficient acceptance evidence.

Client aliases exist in two layers

The client app configuration uses ES2023, DOM types, react-jsx, bundler resolution, and noEmit. The node project checks Vite configuration using Node types, rather than copying the DOM setup. Both also have options such as verbatimModuleSyntax; reading an old base configuration misses these actual settings.

TypeScript paths maps @/* to src. vite.config.ts separately defines the build alias using import.meta.dirname. Type and bundler resolution each have configuration, so changing one does not establish that the other follows. The official paths reference explains that paths does not rewrite emitted import paths.

Reproduce an alias counterexample in isolation

The client aliases typescript to the @typescript/typescript6 6.0.2 wrapper. Its underlying dependency resolves to the actual TypeScript 6.0.3 compiler in the lockfile. The exercise directly installs that compiler version, avoiding confusion between wrapper and compiler versions. It runs no ChatCrystal build or release script. Create the following files in a new directory and use Node 24+.

Declare the teaching project’s ESM scope in package.json:

{ "private": true, "type": "module" }

Install the pinned compiler:

npm install --ignore-scripts --no-audit --no-fund typescript@6.0.3

Save tsconfig.json. Explicit rootDir determines output layout. This small project emits JS, unlike the client’s noEmit configuration.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "rootDir": "src",
    "outDir": "dist",
    "paths": { "@lesson/*": ["./src/*"] }
  },
  "include": ["src"]
}

Create src and save src/values.ts:

export const value = 42;

Save src/main.ts, initially retaining a deliberate alias:

import { value } from '@lesson/values.js';
console.log(value);

Compile, then run:

node node_modules/typescript/bin/tsc --project tsconfig.json
node dist/main.js

Compilation should pass, leaving @lesson/values.js in emitted main.js. Ordinary Node cannot resolve this custom alias, so the second command is expected to fail with ERR_MODULE_NOT_FOUND. No bundler, loader, or import mapping is configured here.

Replace src/main.ts’s import with the relative path below. Running the same two commands again should compile and print 42:

import { value } from './values.js';
console.log(value);

Failure followed by recovery demonstrates a resolution boundary. It does not establish relative imports as the only solution for every project. A builder or runtime-supported mapping is another option, requiring its own configuration and verification.

Electron’s Node16 mode is not a universal CommonJS switch

electron/tsconfig.json uses Node16 modules and resolution, Node and Electron types, strict, and sourceMap. Node16 considers extensions and package scope when determining module form. Currently electron has no package.json of its own, and the root package lacks type:module. Existing ordinary .ts main-process files therefore occupy a CommonJS package scope. This observation does not mean Node16 always emits only CommonJS.

Before adding options, identify who checks types, emits files, resolves paths, and executes production output. Then inspect shared types, resource copying, and the real start entry. Strict checking catches some type errors; it does not validate external JSON, file existence, or the entire packaging lifecycle. This article runs only the temporary counterexample, without packaging the application or claiming that configuration changes must succeed.

Share:

Related Posts

Five AI conversation parsers: retained fields and loss boundaries

Build an acceptance matrix from Claude Code, Codex, Cursor, Trae, and Copilot parsers, checking tool flags, thinking, order, and snapshot coverage.

曾亮懿 曾亮懿
4 min read
#ChatCrystal #TypeScript #Node.js

AI SDK Provider Abstraction: Capabilities and Protocols

Test 7 ChatCrystal providers and AI SDK factories with offline mocks for Responses, Chat Completions, embeddings, and structured output rejection.

曾亮懿 曾亮懿
4 min read
#ChatCrystal #Node.js #TypeScript

SourceAdapter internals: identity, content hashes, and reimport

Trace ChatCrystal’s registry, importer, and remote payload to verify identity, canonical hashes, metadata shortcuts, and parser-version boundaries.

曾亮懿 曾亮懿
4 min read
#ChatCrystal #TypeScript #Node.js

From writing to code

Explore ChatCrystal, developer tools and daily learning notes on my GitHub. Questions about the implementation are welcome.