Skip to content

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's match(...), not by poking at properties — and close it with .exhaustive().
  • Import shared code through the @/ alias and a directory's index.ts, never by reaching into its internals.
  • main is production. Work happens on ephemeral branches and reaches main only 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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
import { Component } from '@/common';

rather than:

ts
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:

ts
// 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:

PrefixFor
feat / featureNew functionality, or changes to existing functionality
fix / bugOne or more related defects
taskScoped work that isn't a feature or a fix
refactorRestructuring without behavioural change
docsDocumentation
ciBuild, 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:

PrefixRelease
fix:Patch
feat:Minor
Any type with BREAKING CHANGE: in the bodyMajor
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:

WhereWhat
apps/*/src/**/*.spec.tsLogic, beside its source file
apps/templates-api/src/routes/**/*.spec.tsEndpoint contracts
tests/endpointsCross-cutting endpoint tests
sh
yarn test                  # endpoints, then backend, then e2e
yarn nx test builder-ui    # one project
yarn affected:test         # only what the current change touches

Vitest 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.