Code conventions for this repository. See CONTRIBUTING.md for the process around issues and pull requests.
Prettier (pnpm run style) and ESLint (pnpm run lint) enforce what can be enforced mechanically. The rules below are the conventions followed by this repo and are not enforced by tooling.
- ESM only — every package ships
"type": "module". - Always import explicitly; never rely on globals (e.g. no global
React). The only exception is jest's ambient test globals. - Prefer named imports over namespace or barrel imports:
import { useEffect } from 'react', notimport * as React from 'react'. - Relative imports carry an explicit
.jsextension, even from a.tssource:import { httpLogger } from './logger.js'. ESLint'simport/extensionsrule is deliberately off, so nothing catches a missing extension for you. - Prefer named exports over
export default. Only use a default export when something outside your control requires it (avite.config.ts, a framework's route convention, …).
- Files are kebab-case:
lex-error.ts,write-operation-builder.ts,input-new-password.tsx. - Exception — XRPC route handlers. Under
src/api/**in the service packages, the file name mirrors the lexicon method name and is therefore camelCase: packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts. - Exception — React components. A file exporting a single component may be named after it, in PascalCase (
ProfileCard.tsx).
- Type explicitly where types originate, rely on inference everywhere else. Annotate public API surfaces: exported functions, class members, and module-level constants whose type isn't obvious.
- Never re-annotate what the call site already provides.
expressApp.use((...args) => …)andonChange={(event) => …}need no parameter or return type annotations — TypeScript infers them from the expected callback type. - Prefer
export functionover an arrow function assigned to a const. Function declarations support overload signatures, which arrow consts cannot express.
-
Write for an experienced developer. A comment earns its place by explaining something the code can't: a non-obvious invariant, a subtle edge case, a workaround for an upstream bug, or why a counter-intuitive approach was chosen.
-
Keep them short. One or two lines is usually enough. Don't write a tutorial where a sentence suffices.
-
Delete comments that restate the code.
// increment the counterabovecounter++is noise. Remove such comments when you touch the surrounding code. -
Mark explanatory comments with
@NOTEand deferred work with@TODO. These two annotations are the only ones used in this repo — noFIXME, noHACK, and no bareTODO:.// @NOTE keep in sync with same interface in bsky/src/image/invalidator.ts // @TODO drop dependency on uint8arrays package once Uint8Array.fromBase64 lands
-
Document public APIs with JSDoc: a short description and, where it helps, an
@exampleblock. Don't restate parameter and return types — TypeScript already carries those. Reserve@param/@returnsfor saying something the signature doesn't.
- Don't add new dependencies without strong justification.
- Reference internal packages with the workspace protocol (
workspace:^); never pin them to a published version. @atproto/apiis being replaced by@atproto/lex. Never add@atproto/apias a new dependency; use the@atproto/lexfamily instead. It remains in use in packages/ozone and in some test suites (pds,bsky,dev-env) — keep using it there until those are migrated.- No new circular dependencies, explicit or implicit. The only tolerated cycle is
pds↔bskyin tests. This applies to comments too: a dependency package must never reference an implementation detail of a package that depends on it.
-
Custom error classes are suffixed
Errorand declarenameas a class field matching the class name, so the name is set once at the declaration rather than in every constructor:export class XrpcResponseError extends XrpcError { name = 'XrpcResponseError' }
Older packages predate this and omit the field — follow the convention in new code rather than propagating the old shape.
-
Never write NSID string literals in a package that uses
@atproto/lex. Import the generated schema and use the constant it exposes:Lexicon definition Use record / typed object app.bsky.feed.post.$typequery / procedure / subscription app.bsky.feed.getPosts.$lxmtoken app.bsky.feed.defs.requestLess.$tokenEach constant is emitted once per schema, so every reference shares one string instance instead of duplicating the literal at each call site — and the schema stays the single source of truth.
- Never instantiate a logger inline. Each package declares its loggers as exported constants in its own
src/logger.ts, built withsubsystemLogger('<package>:<subsystem>'), and imports them where needed.
- A class owning a resource (server, subscription, DB handle) implements
async [Symbol.asyncDispose](), typically delegating to its existingdestroy()/close(). Consumers then useawait usinginstead oftry/finally.
- Don't refactor unrelated code. Keep the diff to what the change actually requires.
- When removing code, don't leave references to it. Comments, docs, and names must describe the current state only — no "previously…", "used to…", or mentions of a deleted symbol.