Ways of working
At a glance
- Define simple functions above the complex ones that call them, so a file reads top-down.
- Narrow types with
ts-pattern'smatch(...), not by poking at properties — and close it with.exhaustive(). - Import shared code through the
@/alias and a directory'sindex.ts, never by reaching into its internals. mainis production. Work happens on ephemeral branches and reachesmainonly through a reviewed PR.
Protocols and standards for developing this application: code style, version control, and how work reaches production.
Code style
Definition hierarchy
Within a single file, define a function before the functions that call it. That puts simple helpers at the top and complex operations at the bottom, so a file can be read top-down:
const simpleOperation = (a: number, b: number) => {
return a + b;
};
const complexOperation = () => {
simpleOperation(10, 10);
};The reverse arrangement forces a reader to scroll past a call to find out what it does.
Narrowing types
Don't branch on a type's properties directly. Given these types:
type Panda = { color: 'white' };
type Duck = { color: 'brown' };
type Animal = Panda | Duck;checking animal.color === 'brown' to decide "this is a duck" works until someone changes Duck to { color: 'pink' } — at which point the check silently stops matching and every duck starts doing panda things. Nothing fails loudly; the control flow is just wrong.
This codebase narrows with ts-pattern instead, which ties the branch to the type rather than to one of its current values:
import { match } from 'ts-pattern';
const animalOperation = (animal: Animal) =>
match(animal)
.with({ color: 'brown' }, (duck) => {
// narrowed to Duck
})
.otherwise(() => {
// everything else
});Exhaustiveness
TypeScript has no built-in exhaustiveness check for a plain switch, so a new enum member can be added and silently go unhandled everywhere. Close a match with .exhaustive() and the compiler reports every site that doesn't handle the new case:
const sound = (animal: Animal) =>
match(animal)
.with({ type: 'dog' }, () => 'Woof!')
.with({ type: 'cat' }, () => 'Meow!')
.with({ type: 'bird' }, () => 'Tweet!')
.exhaustive();This is the dominant pattern in apps/builder-ui: roughly seventy match(...) call sites, thirty-five of them closed with .exhaustive(), against a handful of switch statements. Prefer match for anything that branches on a union or enum. If you do write a switch, give it a default that throws, so an unhandled case fails loudly instead of falling through.
Imports
Globally reused paths are defined in tsconfig.json under compilerOptions > paths, so a deeply nested file imports:
import { Component } from '@/common';rather than:
import { Component } from '../../../../common';Directory APIs
Each directory's index.ts is its public surface. Export from it what other directories may use, and import through it rather than reaching into a file directly:
// component/index.ts — wildcard only when everything here is public
export * from './component';
// otherComponent/otherComponent.tsx
import { Component } from '../component';Importing '../component/component' instead defeats the point: it's no longer clear which parts of that directory are internal and which are shared, and moving a file inside it becomes a breaking change for unrelated code.
Version control
Branches
main is the one persistent branch and always reflects production. Everything else is ephemeral, created for one piece of work and deleted once its pull request is merged.
Prefixes in use, by convention rather than enforcement:
| Prefix | For |
|---|---|
feat / feature | New functionality, or changes to existing functionality |
fix / bug | One or more related defects |
task | Scoped work that isn't a feature or a fix |
refactor | Restructuring without behavioural change |
docs | Documentation |
ci | Build, deployment and workflow changes |
Both short and long forms of the first two are in active use across the repository. Pick one and stay consistent within a piece of work.
DANGER
Never push directly to main. It runs validated production code, and anything landing there is deployed automatically — a bad push reaches end users.
Pull requests
When a piece of work is finished, open a pull request into main. It should be reviewed by someone other than its author before merging.
Note that this is a convention, not something the repository enforces: main has no branch protection, so nothing technically blocks an unreviewed merge or a direct push. The rule holds regardless.
Commit messages decide the release
main runs semantic-release on every push, and it reads commit messages to work out the next version. Conventional Commits, so the type prefix is not cosmetic:
| Prefix | Release |
|---|---|
fix: | Patch |
feat: | Minor |
Any type with BREAKING CHANGE: in the body | Major |
docs:, ci:, chore:, test:, refactor:, style: | None |
The last row has a consequence worth knowing. A docs:-only merge cuts no version, so nothing new is built or deployed — a documentation change can sit on main without reaching any environment that deploys by tag.
Tests
Specs live next to what they test, except endpoint contract tests:
| Where | What |
|---|---|
apps/*/src/**/*.spec.ts | Logic, beside its source file |
apps/templates-api/src/routes/**/*.spec.ts | Endpoint contracts |
tests/endpoints | Cross-cutting endpoint tests |
yarn test # endpoints, then backend, then e2e
yarn nx test builder-ui # one project
yarn affected:test # only what the current change touchesVitest everywhere, with Playwright reserved for real browser flows. When running a single spec through Nx, the path is relative to the project, not the repository root — src/utils/thing.spec.ts, not apps/builder-ui/src/utils/thing.spec.ts. A repo-relative path matches nothing and exits non-zero, which looks exactly like a failing test.