TypeScript Guide¶
GLIMPSE is written for Python, but nothing in its layer rules depends on the
language. This page maps the Python vocabulary to TypeScript and covers what
the move changes: how the request carries services, how a browser bundle fits
in, and how to enforce imports without importlinter.
The layers, their import rules and the slicing axes stay as they are. Read Layers first; this page only translates.
Translation table¶
| Python | TypeScript |
|---|---|
typing.Protocol |
interface |
| Implementation names its protocol | class X implements Protocol |
| DTO (Pydantic, dataclass) | interface or type with readonly fields, or z.infer of a zod schema |
Write TypedDict without id |
type CreateX = Omit<X, "id"> |
| Coarse error class | class NotFoundError extends Error |
@cached_property on Services |
Fields built in a per-request factory, or a ??= getter |
@functools.lru_cache factory |
A globalThis slot assigned once with ??= |
__init__.py facade |
index.ts barrel |
RootRequest typing-only subclass |
Declaration merging on the framework's request locals |
[project.scripts] → inits.cli:run |
package.json bin → a file in inits |
importlinter contracts |
dependency-cruiser rules |
MagicMock for a repository protocol |
An object literal of vi.fn() (Vitest) or jest.fn() |
with transaction.atomic(): |
await transaction.atomic(async () => { ... }) |
One Python caveat disappears: a class that implements an interface and
misses a method fails to compile, so the "an explicit Protocol subclass
inherits stub bodies" trap has no TypeScript equivalent. One is new: a
type-only import (import type) is erased at runtime but is still a
dependency on the layer it names, and the import rules apply to it.
Project layout¶
A young project keeps the axis-free layers flat, under src/:
src/
├── pacts.ts
├── specs.ts
├── mills.ts
├── inits/
│ ├── services.ts # createServices(), process-wide clients
│ └── server.ts # builds the app, starts listening
├── links/
│ └── db/
│ └── postgres.ts
└── gates/
└── web/
└── hono.ts
inits is a folder here because a server needs an entry file that runs, and
inits splits however is convenient. package.json
names the entry file — "start": "node dist/inits/server.js" — the way
pyproject.toml names inits.cli:run.
edges is the repository root. Its Python contents — settings, manage.py,
wsgi.py — map to package.json, tsconfig.json, .env and the framework's
config file (astro.config.mjs, vite.config.ts). The runtime reads them, no
project code imports them, and they name project code only by path string.
Python needs an empty edges/ package because an import contract can name
nothing that is missing; the TypeScript rules below match paths by regex, so
no placeholder folder is needed.
Promotion works as in Python: pacts.ts becomes pacts/proposals.ts and
pacts/services.ts once it earns it, and imports
change to the defining module. ./pacts resolves to either pacts.ts or
pacts/index.ts, but the re-export policy holds:
no barrel by default, a facade index.ts only for a links adapter.
pacts — interfaces, data and errors¶
// src/pacts.ts
export interface Proposal {
readonly id: number;
readonly title: string;
readonly authorId: number;
}
export type CreateProposal = Omit<Proposal, "id">;
export interface AuditEntry {
readonly action: string;
readonly entityId: number;
}
export class NotFoundError extends Error {
override name = "NotFoundError";
}
export class DatabaseConstraintError extends Error {
override name = "DatabaseConstraintError";
}
export interface ProposalRepository {
get(id: number): Promise<Proposal>;
markPublished(id: number): Promise<void>;
}
export interface AuditRepository {
record(entry: AuditEntry): Promise<void>;
}
export interface Transaction {
atomic<T>(work: () => Promise<T>): Promise<T>;
savepoint<T>(work: () => Promise<T>): Promise<T>;
}
export interface ProposalService {
get(id: number): Promise<Proposal>;
publish(id: number): Promise<void>;
}
export interface Services {
proposals: ProposalService;
}
The transaction takes a callback. TypeScript has no with, and
await using cannot tell a dispose that the block threw, so it cannot choose
between commit and rollback. atomic(work) commits when work resolves and
rolls back when it rejects. savepoint(work) follows the same shape: a
violated constraint rolls back only its block and surfaces as
DatabaseConstraintError, leaving the surrounding transaction usable.
A zod schema is a DTO too. When a gate parses input with zod, the schema
is the contract and the type comes from z.infer. It lives in pacts beside
the types it shapes. A refinement that checks meaning — a seat limit, a
reference to something that must exist — belongs in a mill, as in Python:
gates validate format, mills validate
meaning.
mills — services¶
// src/mills.ts
import type {
AuditRepository,
Proposal,
ProposalRepository,
ProposalService,
Transaction,
} from "./pacts";
export class Proposals implements ProposalService {
constructor(
private readonly proposals: ProposalRepository,
private readonly audit: AuditRepository,
private readonly transaction: Transaction,
) {}
get(id: number): Promise<Proposal> {
return this.proposals.get(id);
}
async publish(id: number): Promise<void> {
await this.transaction.atomic(async () => {
await this.proposals.markPublished(id);
await this.audit.record({ action: "publish", entityId: id });
});
}
}
Mills have no side-effect imports. In Node terms: no node:fs, node:net,
node:child_process, no database driver, no HTTP client, no process.env.
The clock, a random draw and a log line stay fine. Timers (setTimeout,
setInterval) own control flow, so a mill does not start them: a scheduler
is an entry point and lives in gates.
links — adapters¶
// src/links/db/postgres.ts
import { AsyncLocalStorage } from "node:async_hooks";
import pg from "pg";
import {
DatabaseConstraintError,
NotFoundError,
type Proposal,
type ProposalRepository,
type Transaction,
} from "../../pacts";
/** The pool, and the client of the transaction in progress. */
export class PgDatabase {
readonly transaction = new AsyncLocalStorage<pg.PoolClient>();
constructor(readonly pool: pg.Pool) {}
/** Runs on the transaction's client inside `atomic`, the pool outside. */
query<R extends pg.QueryResultRow>(
text: string,
values?: unknown[],
): Promise<pg.QueryResult<R>> {
const client = this.transaction.getStore();
return client
? client.query<R>(text, values)
: this.pool.query<R>(text, values);
}
}
interface ProposalRow {
id: number;
title: string;
author_id: number;
}
const toProposal = (row: ProposalRow): Proposal => ({
id: row.id,
title: row.title,
authorId: row.author_id,
});
export class PgProposalRepository implements ProposalRepository {
constructor(private readonly db: PgDatabase) {}
async get(id: number): Promise<Proposal> {
const { rows } = await this.db.query<ProposalRow>(
"SELECT id, title, author_id FROM proposals WHERE id = $1",
[id],
);
const row = rows[0];
if (!row) throw new NotFoundError(`proposal ${id}`);
return toProposal(row);
}
async markPublished(id: number): Promise<void> {
await this.db.query(
"UPDATE proposals SET published = true WHERE id = $1",
[id],
);
}
}
The row type and the mapping stay inside the adapter; only Proposal leaves.
PgAuditRepository follows the same pattern.
The Transaction implementation holds a connection, so it is an adapter too,
in the same file:
export class PgTransaction implements Transaction {
private savepoints = 0;
constructor(private readonly db: PgDatabase) {}
async atomic<T>(work: () => Promise<T>): Promise<T> {
if (this.db.transaction.getStore()) return work();
const client = await this.db.pool.connect();
let broken: Error | undefined;
try {
await client.query("BEGIN");
const result = await this.db.transaction.run(client, work);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK").catch((rollbackError: Error) => {
broken = rollbackError;
});
throw error;
} finally {
// Releasing with an error discards the client instead of pooling it.
client.release(broken);
}
}
async savepoint<T>(work: () => Promise<T>): Promise<T> {
const client = this.db.transaction.getStore();
if (!client) return this.atomic(() => this.savepoint(work));
// A failed ROLLBACK TO or RELEASE keeps the savepoint, so a reused name
// would hit the inner one.
const name = `sp${++this.savepoints}`;
await client.query(`SAVEPOINT ${name}`);
try {
const result = await work();
await client.query(`RELEASE SAVEPOINT ${name}`);
return result;
} catch (error) {
const rolledBack = await client
.query(`ROLLBACK TO SAVEPOINT ${name}`)
.then(() => client.query(`RELEASE SAVEPOINT ${name}`))
.then(
() => true,
() => false,
);
// SQLSTATE class 22 is a data exception, 23 an integrity violation.
// Without the rollback the transaction is aborted, so it stays fatal.
if (
rolledBack &&
error instanceof pg.DatabaseError &&
/^2[23]/.test(error.code ?? "")
) {
throw new DatabaseConstraintError(error.message, { cause: error });
}
throw error;
}
}
}
The repositories and the transaction share one PgDatabase. Inside atomic,
AsyncLocalStorage hands every query the client that ran BEGIN, across
each await, without the client passing through mills. A nested atomic
joins the outer transaction.
A query builder or ORM (Drizzle, Kysely, Prisma) changes nothing here: its
schema and generated types are the adapter's models, internal to
links/db/{adapter}/, and repositories map their rows to pacts types.
inits — composition¶
// src/inits/services.ts
import pg from "pg";
import {
PgAuditRepository,
PgDatabase,
PgProposalRepository,
PgTransaction,
} from "../links/db/postgres";
import { Proposals } from "../mills";
import type { Services } from "../pacts";
const shared = globalThis as typeof globalThis & { pgPool?: pg.Pool };
/** One pool per process, kept when a dev server re-evaluates this module. */
function getPool(): pg.Pool {
// Without an error listener, an idle client's error crashes the process.
shared.pgPool ??= new pg.Pool({
connectionString: process.env.DATABASE_URL,
}).on("error", (error) => console.error(error));
return shared.pgPool;
}
/** One container per request. */
export function createServices(): Services {
const db = new PgDatabase(getPool());
return {
proposals: new Proposals(
new PgProposalRepository(db),
new PgAuditRepository(db),
new PgTransaction(db),
),
};
}
Configuration enters here: inits reads process.env and hands each value to
the leaf that needs it. A link that reads process.env itself is the same
drift as in Python.
Node runs one thread, so the Python reason for a per-request container — the
request object as the thread-safety seam — does not apply. A per-request
container is still the default, because request-scoped state (the current
user, an open transaction) has an obvious home. A process-wide Services is
fine while no service holds request state.
gates — and how they get services¶
How inits reaches gates is per-port, and on the web
it depends on who owns dispatch.
The app is a value: Hono, Express, Fastify¶
Your code builds the app object and starts the server, so nothing dispatches
to gates from outside. This is the CLI case: inits imports the gate's
routes, adds the middleware that attaches services, and starts listening.
// src/gates/web/hono.ts
import { Hono } from "hono";
import { NotFoundError, type Services } from "../../pacts";
/** Typing only: inits' middleware sets `services` on every request. */
export type WebEnv = { Variables: { services: Services } };
export const routes = new Hono<WebEnv>().get("/proposals/:id", async (c) => {
const id = Number(c.req.param("id"));
if (!Number.isInteger(id)) return c.json({ error: "id must be an int" }, 400);
try {
return c.json(await c.var.services.proposals.get(id));
} catch (error) {
if (error instanceof NotFoundError) {
return c.json({ error: error.message }, 404);
}
throw error;
}
});
// src/inits/server.ts
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { routes, type WebEnv } from "../gates/web/hono";
import { createServices } from "./services";
const app = new Hono<WebEnv>()
.use(async (c, next) => {
c.set("services", createServices());
await next();
})
.route("/", routes);
serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000) });
WebEnv is the RootRequest of this stack: a typing-only declaration in the
gate, naming Services from pacts. The gate catches NotFoundError at the
call site and decides it means 404 — there is no central error-to-status map.
The framework dispatches: Astro, SvelteKit¶
File-based routing puts gates where the framework looks for them —
src/pages/ in Astro, src/routes/ in SvelteKit. Those directories are the
web port of gates; name them in the routed pattern
below instead of moving them under gates/web/. A
browser-side island or component is a gate too.
The framework also dictates where the root middleware file sits. Keep the
code in inits and leave a one-line re-export at the framework's path:
// src/middleware.ts — Astro looks for this file; the code is inits'
export { onRequest } from "./inits/middleware";
// src/inits/middleware.ts
import { defineMiddleware } from "astro:middleware";
import { createServices } from "./services";
export const onRequest = defineMiddleware((context, next) => {
context.locals.services = createServices();
return next();
});
The request type comes from declaration merging, again typing only:
// src/env.d.ts
declare namespace App {
interface Locals {
services: import("./pacts").Services;
}
}
SvelteKit has the same pieces: handle in src/hooks.server.ts sets
event.locals.services, and src/app.d.ts types App.Locals.
A browser bundle¶
A frontend that ships to the browser is a second program, and every part of
it is a gate: it imports only pacts and reaches services over HTTP. Two
consequences:
pactsmust stay importable in a browser. Apactsmodule that importsnode:fsor a database driver breaks the client bundle. Types shared by both sides — response shapes, zod schemas for form input — live inpacts.- The browser's fetch wrapper is gate code, inside the web adapter. It is
not a
linksadapter: it talks to this system's own services, not to an external capability.
Enforcing the imports¶
dependency-cruiser checks
resolved module paths, the way importlinter checks module names, so path
aliases and index.ts files do not hide an import. The rules below mirror
the import contracts, with one gap: edges importing
project code is left to review.
// .dependency-cruiser.cjs
const layer = (name) => `^src/${name}([.]ts$|/)`;
// Where the framework routes from: the web port of gates.
const routed = "^src/(pages|components)/";
const gates = `^src/gates/|${routed}`;
module.exports = {
forbidden: [
{
name: "pacts",
severity: "error",
from: { path: layer("pacts") },
to: { path: [layer("(specs|mills|links|inits)"), gates] },
},
{
name: "specs",
severity: "error",
from: { path: layer("specs") },
to: { path: [layer("(mills|links|inits)"), gates] },
},
{
name: "mills",
severity: "error",
from: { path: layer("mills") },
to: { path: [layer("(links|inits)"), gates] },
},
{
name: "links",
severity: "error",
from: { path: layer("links") },
to: { path: [layer("(specs|mills|inits)"), gates] },
},
{
name: "gates",
severity: "error",
from: { path: gates },
to: { path: layer("(specs|mills|links|inits)") },
},
{
name: "inits",
severity: "error",
from: { path: layer("inits") },
to: { path: layer("specs") },
},
{
name: "no Node built-ins in pacts, specs, mills",
severity: "error",
from: { path: layer("(pacts|specs|mills)") },
to: { dependencyTypes: ["core"] },
},
{
name: "no effectful packages in pacts, specs, mills",
severity: "error",
from: { path: layer("(pacts|specs|mills)") },
to: { path: "node_modules/(@types/)?(pg|undici)/" },
},
{
name: "edges",
severity: "error",
from: { path: "^src/" },
to: {
pathNot: "^(src|node_modules)/",
dependencyTypesNot: ["core"],
couldNotResolve: false,
},
},
{
name: "ports are independent",
severity: "error",
from: { path: "^src/(gates|links)/([^/]+)/" },
to: {
path: "^src/(gates|links)/",
pathNot: "^src/$1/$2/",
},
},
{
name: "routed directories are the web port",
severity: "error",
from: { path: routed },
to: { path: "^src/gates/", pathNot: "^src/gates/web/" },
},
{
name: "other ports stay out of routed directories",
severity: "error",
from: { path: "^src/gates/", pathNot: "^src/gates/web/" },
to: { path: routed },
},
{
name: "no cycles",
severity: "error",
from: {},
to: { circular: true },
},
],
options: {
doNotFollow: { path: "node_modules" },
tsPreCompilationDeps: true,
tsConfig: { fileName: "tsconfig.json" },
},
};
Run it with depcruise src in CI. Set routed to the directories your
framework routes from — ^src/routes/ in SvelteKit.
tsPreCompilationDeps: trueis required. Without it, dependency-cruiser reads the compiled output, whereimport typeis gone, and type-only imports slip past every rule.dependencyTypes: ["core"]bans Node built-ins, and the rule after it bans effectful npm packages. List the ones your project uses — a driver, an HTTP client, an SDK;@types/catches a type-only import. Together they are the mills framework contract at level C for imports. Globals need the linter, below.edgesis everything outsidesrc/andnode_modules/. Theedgesrule stops project code importingpackage.jsonorvite.config.ts. It skips imports that resolve to no file, such asastro:middleware. A generated type directory that a file insrc/references, such as.astro/, goes into itspathNot. A config file importingsrc/is not caught:depcruise srcdoes not read config files.- Type-only imports count.
pactsimporting a type frommillsis stillpacts → mills.
A lighter option is the linter's no-restricted-imports with regex
patterns, scoped per layer through overrides (ESLint and oxlint both
support it). It needs no extra tool, but it matches the import string, not
the resolved file: relative paths need patterns like ^(\.\.?/)+mills(/|$),
and a path alias such as @/mills needs its own pattern.
Globals need no import, so no import rule sees fetch, process,
setTimeout, setInterval or WebSocket. Ban them in pacts, specs and
mills with the linter's no-restricted-globals, scoped through overrides
the same way. Date and crypto stay allowed: the clock and a random draw
are fine in a mill.
Neither tool sees calls. A gate opening a transaction, a call through
globalThis.fetch and a business rule in a zod refinement stay review
concerns, as listed in
What the linter cannot catch.
Testing¶
The layer decides the test type, whatever the runner. A mill gets a unit test with mocked repositories:
// mills.test.ts
import { expect, it, vi } from "vitest";
import { Proposals } from "./mills";
it("publishes and audits in one transaction", async () => {
let inside = false;
const opened = vi.fn();
const transaction = {
async atomic<T>(work: () => Promise<T>): Promise<T> {
opened();
inside = true;
try {
return await work();
} finally {
inside = false;
}
},
savepoint: <T>(work: () => Promise<T>): Promise<T> => work(),
};
const inTransaction = async () => {
expect(inside).toBe(true);
};
const proposals = { get: vi.fn(), markPublished: vi.fn(inTransaction) };
const audit = { record: vi.fn(inTransaction) };
await new Proposals(proposals, audit, transaction).publish(7);
expect(opened).toHaveBeenCalledOnce();
expect(proposals.markPublished).toHaveBeenCalledWith(7);
expect(audit.record).toHaveBeenCalledWith({
action: "publish",
entityId: 7,
});
});
links and gates get integration tests against real infrastructure. For a
gate, that means a full request: app.request("/proposals/7") in Hono,
supertest for Express, the framework's container or a running dev server
for Astro and SvelteKit. Assert the whole response.
TypeScript projects usually keep tests next to the code (mills.test.ts)
instead of a root tests/ tree. Either works if the unit/integration split
stays visible — a directory, or a suffix such as *.int.test.ts that the
runner can select.