Style Guide
This document defines the concrete implementation rules, naming conventions, and formatting standards for the Ndigo codebase. For architectural principles and design patterns, see the Architecture Overview.
Purpose and Scope
Section titled “Purpose and Scope”This guide answers: “How do I name this file/variable/class?” and “How should this code be formatted?”
See the Architecture Overview for: “Why is the system designed this way?” and “What are the architectural patterns?”
Enforcement markers: Headings marked [AUTOMATED: …] name the tool that enforces the rule.
[AUTOMATED: oxfmt]— enforced by oxfmt (pnpm format:check,.oxfmtrc.json).[AUTOMATED: oxlint]— enforced by oxlint (pnpm lint,.oxlintrc.json); the lane runsoxlint --type-aware --deny-warnings, so awarn-level rule fails CI exactly like anerror. The enforcing rule names (for exampleinternal/no-internal-file-exports) are named inline.
Every other rule is checked in code review.
1. Formatting Rules
Section titled “1. Formatting Rules”These rules are enforced by oxfmt (.oxfmtrc.json). When the oxfmt configuration changes, this section must be updated.
Ignored files.
.oxfmtrc.json’signorePatternsexcludes the files another tool owns the bytes of:AGENTS.mdand.github/copilot-instructions.md(graft rewrites their context blocks and strips the blank line oxfmt wants after thegraft:startmarker) andopencode.json(OpenCode rewrites its own config).pnpm format:checkcannot hold a formatting contract for a file a generator rewrites.
1.1. Indentation [AUTOMATED: oxfmt]
Section titled “1.1. Indentation [AUTOMATED: oxfmt]”- TypeScript/JavaScript: 4 spaces
- JSON/YAML: 2 spaces
- Never use tabs (
useTabs: false)
Examples:
// ✅ Correct: 4 spaces for TS/JSfunction example() { const value = 42; return value;}{ "name": "@ndigo/core", "version": "3.0.0-alpha.0"}1.2. Line Endings [AUTOMATED: oxfmt]
Section titled “1.2. Line Endings [AUTOMATED: oxfmt]”- Always use LF (
\n) on all platforms - Never use CRLF (
\r\n) - Configured:
endOfLine: 'lf'
1.3. Semicolons [AUTOMATED: oxfmt]
Section titled “1.3. Semicolons [AUTOMATED: oxfmt]”- Always required at the end of statements
- Configured:
semi: true
// ✅ Correctconst value = 42;export default MyClass;
// ❌ Wrongconst value = 42;export default MyClass;1.4. Quotes [AUTOMATED: oxfmt]
Section titled “1.4. Quotes [AUTOMATED: oxfmt]”- Single quotes for strings in TypeScript/JavaScript
- Double quotes for JSX attributes
- Configured:
singleQuote: true,jsxSingleQuote: false
// ✅ Correctconst message = 'Hello, world!';import { foo } from '@ndigo/util';
// ❌ Wrongconst message = 'Hello, world!';import { foo } from '@ndigo/util';1.5. Trailing Commas [AUTOMATED: oxfmt]
Section titled “1.5. Trailing Commas [AUTOMATED: oxfmt]”- ES5 style - Use trailing commas where valid in ES5
- Helps with cleaner git diffs
- Configured:
trailingComma: 'es5'
// ✅ Correctconst obj = { name: 'example', value: 42, // ← trailing comma};
const arr = [ 'first', 'second', 'third', // ← trailing comma];
// ❌ Wrong: No trailing comma in function parameters (not valid in ES5)function example( arg1: string, arg2: number // ← oxfmt will remove this) {}1.6. Print Width [AUTOMATED: oxfmt]
Section titled “1.6. Print Width [AUTOMATED: oxfmt]”- Maximum 120 characters per line
- oxfmt will automatically wrap at this width
- Configured:
printWidth: 120
1.7. Arrow Functions [AUTOMATED: oxfmt]
Section titled “1.7. Arrow Functions [AUTOMATED: oxfmt]”- Always use parentheses around parameters
- Configured:
arrowParens: 'always'
// ✅ Correctconst fn = (x) => x * 2;const fn2 = (x, y) => x + y;
// ❌ Wrongconst fn = (x) => x * 2;1.8. Bracket Spacing [AUTOMATED: oxfmt]
Section titled “1.8. Bracket Spacing [AUTOMATED: oxfmt]”- Use spaces inside object literal braces
- Arrays do not get spaces (standard JavaScript)
- Configured:
bracketSpacing: true
// ✅ Correctconst obj = { name: 'example', value: 42 }; // spaces around bracesconst arr = [1, 2, 3]; // no spaces in arrays
// ❌ Wrongconst obj = { name: 'example', value: 42 }; // missing spacesconst arr = [1, 2, 3]; // extra spaces1.9. Markdown Files [AUTOMATED: oxfmt]
Section titled “1.9. Markdown Files [AUTOMATED: oxfmt]”- Preserve prose wrapping - Do not reflow markdown text
- Configured:
proseWrap: 'preserve'
2. File Naming Conventions
Section titled “2. File Naming Conventions”2.1. Source Files
Section titled “2.1. Source Files”Pattern: kebab-case.ts
All TypeScript source files use lowercase with hyphens separating words.
Examples:
✅ media-type.ts✅ content-negotiation-view-resolver.ts✅ to-camel.ts✅ is-constructor.ts✅ rand-int.tsRationale: Kebab-case is URL-friendly, avoids case-sensitivity issues across file systems, and is widely adopted in JavaScript/TypeScript ecosystems.
2.2. Internal/Private Files [AUTOMATED: oxlint]
Section titled “2.2. Internal/Private Files [AUTOMATED: oxlint]”Pattern: _kebab-case.ts
Files prefixed with underscore are internal implementation details.
Automated: internal/no-internal-file-exports (error) rejects underscore files re-exported from
public entry points, and internal/no-internal-file-imports (error) rejects cross-package imports of them.
Rules:
- ❌ Never export in public API (
index.ts) - ❌ Never import across package boundaries
- ✅ Only use within the same package
- ✅ Document purpose in file-level JSDoc
Examples:
✅ _util.ts // Internal utilities✅ _parser.ts // Internal parser helpers✅ _natural-sort.ts // Internal sorting implementation✅ _xprod.ts // Internal cross-product logicUsage:
// ✅ Correct: Import within same package// In packages/ndigo-util/src/string/to-camel.tsimport { helper } from '../_util';
// ❌ Wrong: Import across packages// In packages/ndigo-core/src/media-type.tsimport { helper } from '@ndigo/util/_util'; // NEVER DO THIS2.3. Test Files
Section titled “2.3. Test Files”Unit Tests:
- Pattern:
kebab-case.spec.ts - Location:
test/directory at package root - Test specifications use the same naming as their source files, with
.spec.tssuffix
Examples:
✅ test/media-type.spec.ts✅ test/brace-expression.spec.ts✅ test/to-camel.spec.ts✅ test/is-valid-date.spec.tsBenchmark/Performance Tests:
- Pattern:
*.js(JavaScript) - Location:
benchmark/directory at package root - Note: Currently JavaScript; may migrate to
*.bench.tsin future
Examples:
✅ benchmark/emitting.js✅ benchmark/logging.js2.4. Test Helper Files
Section titled “2.4. Test Helper Files”Pattern: _kebab-case.ts (preferred) or testCamelCase.ts (legacy)
Test utilities and fixtures use underscore prefix (preferred) or camelCase (legacy, acceptable).
Location: test/ directory
Purpose: Shared fixtures, custom assertions, test utilities
Important: Test helper files must contain actual helper functions (same rule as src _*.ts files). Never import test helpers across packages.
Examples:
✅ test/_expect.ts // Custom test assertions (preferred)✅ test/_worker.ts // Test worker utilities (preferred)✅ test/_fixtures.ts // Shared test data✅ test/test-cases.ts // Test data fixtures⚠️ test/testDecorators.ts // Legacy pattern (acceptable, not recommended for new files)⚠️ test/testContext.ts // Legacy pattern (acceptable, not recommended for new files)Example:
// ✅ test/_expect.tsexport const expectSuccess = <T>(parser: Parser<T>, input: string) => { const result = parser.run(input); expect(result.isOk).toBe(true);};2.5. Type Declaration Files
Section titled “2.5. Type Declaration Files”Pattern: kebab-case.d.ts
TypeScript declaration files use kebab-case with .d.ts extension.
Examples:
✅ seedrandom.d.ts✅ _safe-regex.d.ts2.6. Configuration Files
Section titled “2.6. Configuration Files”Pattern: kebab-case.config.ts or kebab-case.json
Configuration files use kebab-case with appropriate extensions.
Examples:
✅ vitest.config.ts✅ vite.config.ts✅ tsconfig.json✅ tsconfig.build.json✅ pnpm-workspace.yaml✅ .oxfmtrc.json✅ .oxlintrc.json2.7. Documentation Files
Section titled “2.7. Documentation Files”Pattern: UPPER_SNAKE_CASE.md (root), YYYY-MM-DD_DESCRIPTION.md (dated), or kebab-case-slug.md (living)
Root-level documentation uses all caps; dated documentation uses an ISO 8601 date with an underscore separator; living documentation uses a stable kebab-case slug.
Root Documentation (Uppercase):
✅ README.md✅ ARCHITECTURE.md✅ STYLE_GUIDE.md✅ AGENTS.md✅ AGENTS.md (formerly CLAUDE.md)Dated Documentation (ISO 8601 + Underscore):
✅ 2025-01-23_DEPENDENCIES.md✅ 2025-02-01_DEPENDENCY_AUDIT.md✅ 2025-01-30_PARSER_ENHANCEMENT.md✅ 2025-01-28_ESM_ONLY.md
❌ 2025-01-23-dependencies.md // Wrong: lowercase, hyphen separator❌ dependencies-2025.md // Wrong: date at end❌ 2025-01-23-DEPENDENCIES.md // Wrong: hyphen separatorFormat: YYYY-MM-DD_DESCRIPTION.md
Where:
YYYY-MM-DD- ISO 8601 date with hyphens_- Underscore separator (consistent with UPPER_SNAKE_CASE)DESCRIPTION- ALL_CAPS with underscores for multi-word phrases
Living Documentation (Stable Slug):
Docs updated in place rather than frozen by date use a stable kebab-case slug with no date:
✅ docs/guides/36-testing.md✅ docs/guides/17-i18n-and-content.md✅ docs/architecture/security.md✅ ARCHITECTURE.md
❌ docs/guides/2026-10-06-testing.md // Wrong: living docs do not carry a dateGuide files carry a zero-padded numeric ordering prefix (NN-slug.md); docs/architecture/ and the root documents use a bare slug. See Contributing for which directories are dated (history) and which are living (updated in place).
3. Code Naming Conventions
Section titled “3. Code Naming Conventions”3.1. Constants
Section titled “3.1. Constants”Pattern: UPPER_SNAKE_CASE for primitive constants and semantic enum-like values, camelCase for all other constants
Primitive Constants (immutable primitive values per @ndigo/core/src/primitive.ts):
Primitive types: boolean | null | number | string | symbol | bigint | undefined
// ✅ Correct: Primitive string constants use UPPER_SNAKE_CASEconst WILDCARD: SupportedMediaType = '*';const QUALITY_FACTOR_PARAM = 'q';const DEFAULT_DATE_FORMAT = 'MM/DD/YYYY';
// ✅ Correct: Primitive number constants use UPPER_SNAKE_CASEconst MAX_RETRIES = 3;const DEFAULT_TIMEOUT = 5000;const HTTP_OK = 200;
// ✅ Correct: Primitive boolean constants use UPPER_SNAKE_CASEconst DEBUG_ENABLED = true;const STRICT_MODE = false;Semantic Enum-Like Constants (immutable object instances acting as enums):
// ✅ Correct: HTTPHeader instances (enum-like semantics) use UPPER_SNAKE_CASEexport const CONTENT_TYPE = new HTTPHeader('Content-Type');export const AUTHORIZATION = new HTTPHeader('Authorization');
// ✅ Correct: MediaType instances (enum-like semantics) use UPPER_SNAKE_CASEexport const APPLICATION_JSON = MediaType.for('application', 'json');export const TEXT_HTML = MediaType.for('text', 'html');export const IMAGE_PNG = MediaType.for('image', 'png');Object Constants (RegExp patterns, object literals, arrays):
All non-primitive constants use camelCase, including RegExp patterns and other immutable objects.
Automated: internal/no-uppercase-regexp-constants (error) rejects UPPER_SNAKE_CASE constants initialized with a RegExp literal.
// ✅ Correct: RegExp patterns use camelCase (they are objects, not primitives)const alphaRegex = /^[a-zA-Z]+$/;const alphaNumericRegex = /^[a-zA-Z0-9]+$/;const queryKeyPattern = /^[A-Za-z+:/?[\]0-9%]+/;const matrixValuePattern = /^[^;]*/;const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;const percentEncodedPattern = /(%[0-9A-Za-z]{2})+/g;
// ✅ Correct: Used in parser combinatorsconst queryKey = regex(queryKeyPattern).map(replacePlusWithSpace).map(decode);
// ❌ Wrong: RegExp patterns with UPPER_SNAKE_CASE (objects, not primitives)const ALPHA_REGEX = /^[a-zA-Z]+$/; // Should be: alphaRegexconst QUERY_KEY_PATTERN = /^[A-Z]+$/; // Should be: queryKeyPatternconst EMAIL_REGEX = /^[a-z@.]+$/; // Should be: emailRegex
// ❌ Wrong: Inline patterns instead of named constantsconst queryKey = regex(/^[A-Za-z+:/?[\]0-9%]+/) // Bad: inline pattern .map(replacePlusWithSpace) .map(decode);Functional Constants (composed functions, still immutable):
// ✅ Correct: Functional constants use camelCaseconst removeSpaces = replace(spaces, '');const removeDashesWithSpaces = replace(dashes, ' ');const naturalComparison = comparator(naturalPredicate);const specificityComparator = new SpecificityComparator();const upperCaseCount: Counter = pipe(toString, removeLowerCase, lengthOf);Rationale: Distinguishes true primitive values (per @ndigo/core/src/primitive.ts: string, number, boolean, null, undefined, symbol, bigint) from all other constants. RegExp objects, arrays, and object literals are not primitives—they have methods and are mutable in structure—so they use camelCase. Semantic enum-like constants (HTTPHeader, MediaType instances) use UPPER_SNAKE_CASE as a special case because they represent immutable enum values in the public API. This follows JavaScript’s semantic distinction: primitives use uppercase (Math.PI, Number.MAX_VALUE), objects use camelCase.
3.2. Variables and Functions
Section titled “3.2. Variables and Functions”Pattern: camelCase
All non-constant variables, functions, parameters, and properties use camelCase.
Functions:
// ✅ Correctfunction toCamel(value: string): string { ... }function toPascal(value: string): string { ... }function isValidDate(value: temporal, format: string): boolean { ... }function randInt(min: number, max: number): number { ... }Variables:
// ✅ Correctconst upperCaseCount: Counter = pipe(toString, removeLowerCase, lengthOf);const patternConstraint = curry(({ regex, flags }: PatternParams, value: string) => ...);const typeRegistry = new Map<string, MediaType>();let currentIndex = 0;Arrow Functions:
// ✅ Correctconst transform = (value: string) => value.toUpperCase();const add = (a: number, b: number) => a + b;
// ✅ Correct: narrow with a return annotation, not an `as` castconst likeThing = (value: unknown): value is Thing => isThingLike(value);A type guard built by a factory (for example like({...}) as (value: unknown) => value is Thing) is a function value, so give it a return annotation when you
can rather than asserting the whole signature — the cast hides the narrowing
from readers and from TypeDoc. Do not add @function to make the page render
as a function: the API reference classifies function-typed constants with the
repo-local plugin described in
Guide 90 — Package Documentation.
3.3. Predicate Functions
Section titled “3.3. Predicate Functions”Pattern: isType prefix for type/identity checks
Predicates that verify a value’s type or identity using typeof, instanceof, or similar runtime checks. The question being asked is: “Is this value an instance of type X?”
// ✅ Correct: typeof checksfunction isString(value: unknown): value is string { return typeof value === 'string';}
function isNumber(value: unknown): value is number { return typeof value === 'number' && !Number.isNaN(value);}
function isConstructor(value: unknown): value is Constructor { return typeof value === 'function' && value.prototype !== undefined;}
function isValidDate(value: temporal, format: string): boolean { return !isNaN(Date.parse(value));}
// ✅ Correct: instanceof checksconst isContextToken = (value: unknown): value is ContextToken<unknown> => value instanceof ContextToken;3.4. Duck Typing Functions
Section titled “3.4. Duck Typing Functions”Pattern: likeType prefix for structural/interface checks
Functions that check whether a value has the expected structural shape (properties and methods) rather than checking identity via typeof or instanceof. The question being asked is: “Does this value behave like type X?” The like() utility from @ndigo/util/types is the standard way to create these checks.
Note: typeof may appear in like* functions as null guards or to verify a property is callable (e.g., typeof value.then === 'function'). This is fine — the key distinction is whether the primary check mechanism tests for structural shape or type identity.
// ✅ Correct: Duck typing predicatesfunction likeArray(value: unknown): value is ArrayLike<any> { return value != null && typeof value === 'object' && 'length' in value;}
function likePromise(value: unknown): value is PromiseLike<any> { return value != null && typeof (value as any).then === 'function';}
function likeController(value: unknown): value is ControllerLike { return ( value != null && typeof value === 'object' && 'handle' in value && typeof (value as any).handle === 'function' );}
function likeComparable<T>(value: unknown): value is Comparable<T> { return value != null && typeof value === 'object' && 'compareTo' in value;}
// ✅ Correct: Using the like() utility from @ndigo/util/typesconst likeFactory = like({ getInstance: Function }) as (value) => value is Factory;const likeEquatable = like({ equals: Function }) as (value) => value is Equatable;Anti-Patterns:
// ❌ Wrong: instanceof is a type/identity check, not duck-typing — use is* prefixconst likeContextToken = (value: unknown): value is ContextToken<unknown> => value instanceof ContextToken; // Should be isContextToken
// ✅ Correct: Use is* for instanceof checksconst isContextToken = (value: unknown): value is ContextToken<unknown> => value instanceof ContextToken;Decision Table:
| Check Mechanism | Prefix | Example |
|---|---|---|
instanceof |
is* |
isContextToken, isDate |
typeof (as primary check) |
is* |
isString, isNumber, isFunction |
like() utility (shape check) |
like* |
likeFactory, likeEquatable |
| Property/method presence | like* |
likeIterable, likePromise |
allPass/anyPass of like* |
like* |
likeApplicationContext, likeHTTPInterceptor |
Rationale: is* asks “Is this value an instance of type X?” — it checks identity. like* asks “Does this value behave like type X?” — it checks structural compatibility. This mirrors the duck-typing philosophy: “If it walks like a duck and quacks like a duck, it’s like a duck.” An instanceof check, by contrast, verifies the value is a duck.
3.5. Classes and Interfaces
Section titled “3.5. Classes and Interfaces”Pattern: PascalCase
All class names, interface names, and type names use PascalCase.
Classes:
// ✅ Correctclass MediaType extends Null implements Serializable {}class DataBinder {}class SpecificityComparator<T> implements Comparator<T> {}class PredicateComparator<T> implements Comparator<T> {}class MediaType extends Null implements Serializable {}Abstract Classes:
// ✅ Correctabstract class AbstractValueComparator implements Comparator<string> {}abstract class BaseController {}Interfaces:
// ✅ Correctinterface Comparable<T> {}interface StringComparable<T> extends Comparable<T> {}interface Comparator<T> {}interface Serializable {}interface ControllerLike {}3.6. Private Fields
Section titled “3.6. Private Fields”Pattern: #privateField (modern) or _legacyPrivate (legacy)
Modern code uses # prefix for private class fields (ES2022). Legacy code may use _ prefix.
Modern (Preferred):
// ✅ Correct: Use # for private fields (ES2022)class DataBinder { #name: string; #target: any; #result: any; #requiredFields: Set<string>;
constructor(name: string) { this.#name = name; }
get name() { return this.#name; // Accessor for private field }}Legacy (Being Phased Out):
// ⚠️ Legacy: Underscore prefix (old code only)class OldComponent { private _enabled: boolean; private _hasView: boolean;
// These are deprecated: __viewName: string; __enabled: boolean;}Rationale: # provides true privacy at the language level (ES2022+), while _ is just a convention. New code should use #.
3.7. Type Definitions
Section titled “3.7. Type Definitions”Pattern: PascalCase for complex types, camelCase for simple type aliases
Complex Types (PascalCase):
// ✅ Correct: Complex types use PascalCasetype SampleFunction = { <T = any>(n: number, list: ArrayLike<T>): T[]; <T = any>(n: number): (list: ArrayLike<T>) => T[];};
type MediaTypeParameters = { [name: string]: string | number | boolean;};
type QualityFactor = `;q=${0.1 | 0.2 | 0.3 | 0.4 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 | 1.0}`;
type SerializableValue = string | number | boolean | null | undefined;Simple Type Aliases (camelCase):
// ✅ Correct: Simple/functional type aliases use camelCasetype comparison<T> = (value1: T, value2: T) => number;type predicate<T> = (value1: T, value2: T) => boolean;type constraint<T = serializable> = (value: T, params?: Params) => boolean;type temporal = string | Date;type pattern = string | RegExp;type serializable = string | number | boolean | null | undefined;Rationale: Complex structural types are treated like interfaces (PascalCase), while simple type aliases and functional types use camelCase for consistency with function naming.
3.8. Decorators
Section titled “3.8. Decorators”Automated: @ndigo/no-cross-paradigm-decorators (error) rejects mixing service-call and route-mapping decorators on one class; decorator naming itself remains a review concern.
Pattern: camelCase
Decorator functions follow standard function naming conventions (camelCase).
Examples:
// ✅ Correct: Decorators use camelCase@configurationclass AppConfig { @entry databaseConnection() {}}
@routeMapping('/api/users')class UserController {}
@entity('users')class User { @column({ name: 'first_name', type: 'varchar' }) firstName: string;}
@scope('singleton')class UserService {}
@viewMapping('user-profile')class UserProfileView {}Rationale: Decorators are functions that modify classes, methods, fields, or parameters. Following standard function naming conventions (camelCase) maintains consistency with JavaScript/TypeScript ecosystem patterns and TC39 decorator standards.
3.9. Acronym Casing [AUTOMATED: oxlint]
Section titled “3.9. Acronym Casing [AUTOMATED: oxlint]”Automated: internal/no-mixed-case-acronyms (error) rejects mixed-case acronyms (Http, Json, Xml) in type declaration names; identifier casing remains a review concern.
Pattern: Consistent casing for acronyms across all contexts
Acronyms must use consistent casing: all-uppercase in type names (classes/interfaces), and all-lowercase OR all-uppercase in identifiers (variables/parameters/functions). Never use mixed-case acronyms like Http, Json, or Xml.
Type Names (Classes/Interfaces/Types):
// ✅ Correct: All-uppercase acronyms in typesclass HTTPTransformer {}interface JSONParser {}type XMLDocument = string;class URLBuilder {}interface APIClient {}class HTMLRenderer {}interface DTOMapper {}
// Real examples from @ndigo/httpexport class HTTP {}export interface HTTPConfig {}export interface HTTPInterceptor {}export class HTTPAppender {}export interface JSONObject {}export interface JSONArray {}export class XMLLayout {}export class URLResource<T> {}
// ❌ Wrong: Mixed-case acronymsclass HttpTransformer {} // Should be HTTPTransformerinterface JsonParser {} // Should be JSONParsertype XmlDocument = string; // Should be XMLDocumentclass UrlBuilder {} // Should be URLBuilderVariable Names (camelCase):
// ✅ Correct: All-lowercase acronyms at start, all-uppercase in middle/endconst httpTransformer: HTTPTransformer = new HTTPTransformer();const jsonParser: JSONParser = new JSONParser();const xmlDocument: XMLDocument = '<root></root>';const urlBuilder: URLBuilder = new URLBuilder();
// ✅ Also correct: All-uppercase acronyms in variable namesconst myHTTPConfig: HTTPConfig = { timeout: 5000 };const userJSONData: JSONObject = { name: 'Alice' };const rawHTMLContent: string = '<div>content</div>';
// Real examples from codebaseconst httpAppender: HTTPAppender = new HTTPAppender();const jsonLayout: JSONLayout = new JSONLayout();const urlResource: URLResource = new URLResource('/path');
// ❌ Wrong: Mixed-case in variablesconst hTTPTransformer = ...; // Should be httpTransformer or myHTTPTransformerconst jSONParser = ...; // Should be jsonParser or userJSONDataconst xMLDocument = ...; // Should be xmlDocument or rawXMLContentFunction Names (camelCase):
// ✅ Correct: Same rules as variablesfunction parseHTTPRequest(request: Request): HTTPRequest {}function formatJSONResponse(data: object): JSONObject {}function buildURL(base: string, params: object): URL {}function renderHTML(template: string): HTMLElement {}
// ❌ Wrong: Mixed-case in function namesfunction parseHttpRequest() {} // Should be parseHTTPRequestfunction formatJsonResponse() {} // Should be formatJSONResponseConstants (UPPER_SNAKE_CASE):
// ✅ Correct: All-uppercase for both acronym and constantconst HTTP_TIMEOUT = 5000;const JSON_MIME_TYPE = 'application/json';const XML_ENCODING = 'utf-8';const API_BASE_URL = 'https://api.example.com';const HTML_DOCTYPE = '<!DOCTYPE html>';
// ❌ Wrong: Mixed-case in constantsconst Http_Timeout = 5000; // Should be HTTP_TIMEOUTconst JSON_Mime_Type = '...'; // Should be JSON_MIME_TYPECommon Acronyms in Ndigo:
Examples from the codebase (not an exhaustive list):
- HTTP - HyperText Transfer Protocol
- JSON - JavaScript Object Notation
- XML - eXtensible Markup Language
- URL / URI - Uniform Resource Locator/Identifier
- API - Application Programming Interface
- HTML - HyperText Markup Language
- CSS - Cascading Style Sheets
- DTO - Data Transfer Object
- DAO - Data Access Object
- UUID - Universally Unique Identifier
- OTEL - OpenTelemetry
Rationale: Consistent acronym casing improves readability and prevents confusion. Modern tooling (autocomplete, minification) eliminates concerns about verbosity. The all-uppercase pattern in type names clearly identifies acronyms, while the all-lowercase or all-uppercase pattern in identifiers maintains readability.
Note: This rule applies to ALL code contexts—classes, interfaces, types, variables, functions, parameters, and constants.
3.10. Interfaces and Abstract Classes
Section titled “3.10. Interfaces and Abstract Classes”Automated: internal/no-abstract-class-without-prefix (error) requires the Abstract prefix on abstract classes; interface naming remains a review concern.
Pattern: Description-first for classes, Abstract prefix for abstract classes
Interface Naming:
Interfaces use the [Domain][Role] pattern to indicate their responsibility within a specific domain.
// ✅ Correct: Interface naming with domain contextinterface HTTPTransformer { transform(request: HTTPRequest): HTTPResponse;}
interface DataTransformer { transform(data: RawData): CleanData;}
interface ViewResolver { resolve(name: string): View;}
interface RouteHandlerAdapter { handle(route: Route): void;}
// Real examples from codebaseexport interface MediaTypeFileExtensionResolver { resolveFileExtensions(mediaType: MediaType): string[];}
export interface RouteHandlerMapping { getHandler(route: Route): Handler | null;}
// ❌ Wrong: Too generic (causes import conflicts)interface Transformer {} // Use HTTPTransformer, DataTransformer, etc.interface Resolver {} // Use ViewResolver, PropertyResolver, etc.interface Handler {} // Use RouteHandler, ErrorHandler, etc.Concrete Class Naming:
Concrete classes implementing interfaces use the description-first pattern: [Description][Role]
The description comes BEFORE the role/interface name to prevent import conflicts and provide clear semantic meaning.
// ✅ Correct: Description BEFORE roleclass UserDataTransformer implements DataTransformer { transform(data: UserRawData): User { return new User(data.name, data.email); }}
class OrderDataTransformer implements DataTransformer { transform(data: OrderRawData): Order { return new Order(data.id, data.items); }}
class PaymentHTTPTransformer implements HTTPTransformer { transform(request: HTTPRequest): HTTPResponse { // Payment-specific transformation }}
// Real examples from codebaseexport class MappingMediaTypeFileExtensionResolver implements MediaTypeFileExtensionResolver { // Maps media types to file extensions}
export class ContentNegotiationViewResolver implements ViewResolver { // Resolves views based on content negotiation}
export class ControllerHandlerAdapter implements RouteHandlerAdapter { // Adapts controller methods to route handlers}
// ❌ Wrong: Role before descriptionclass DataTransformerUser {} // Should be UserDataTransformerclass TransformerUserData {} // Should be UserDataTransformerclass HandlerPaymentHTTP {} // Should be PaymentHTTPHandlerAbstract Class Naming:
Abstract classes use the Abstract prefix followed by the description-first pattern: Abstract[Description][Role]
// ✅ Correct: Abstract prefix with description-firstabstract class AbstractUserDataTransformer implements DataTransformer { // Common transformation logic for user data abstract transform(data: UserRawData): User;
protected validateData(data: UserRawData): boolean { return data != null && data.name && data.email; }}
abstract class AbstractHandlerMapping implements RouteHandlerMapping { // Base implementation for handler mappings abstract getHandler(route: Route): Handler | null;
protected logMapping(route: Route): void { console.log(`Mapping route: ${route.path}`); }}
// Real examples from codebaseexport abstract class AbstractCachingViewResolver implements ViewResolver { // Base class with caching logic for view resolvers}
export abstract class AbstractPathBasedViewResolver extends AbstractCachingViewResolver { // Path-based view resolution with caching}
export abstract class AbstractHandlerMethodAdapter implements RouteHandlerAdapter { // Base adapter for handler methods}
// ❌ Wrong: No Abstract prefix (looks like concrete class)abstract class UserDataTransformer {} // Should be AbstractUserDataTransformerabstract class HandlerMapping {} // Should be AbstractHandlerMapping
// ❌ Wrong: Using "Base" instead of "Abstract"abstract class BaseUserDataTransformer {} // Use Abstract, not BaseInheritance Chains:
When building inheritance hierarchies, maintain the description-first and Abstract prefix patterns:
// ✅ Correct: Clear inheritance chainabstract class AbstractHandlerMapping implements RouteHandlerMapping { abstract getHandler(route: Route): Handler | null;}
abstract class AbstractPathHandlerMapping extends AbstractHandlerMapping { // Adds path matching logic protected matchPath(pattern: string, path: string): boolean { // Implementation }}
abstract class AbstractDetectingPathHandlerMapping extends AbstractPathHandlerMapping { // Adds auto-detection logic protected detectHandlers(): Handler[] { // Implementation }}
class NamePathHandlerMapping extends AbstractDetectingPathHandlerMapping { // Concrete implementation using name-based path mapping getHandler(route: Route): Handler | null { // Implementation }}
// Real example from @ndigo/viewexport abstract class AbstractCachingViewResolver implements ViewResolver { // Base with caching}
export abstract class AbstractPathBasedViewResolver extends AbstractCachingViewResolver { // Adds path-based resolution}
export class ImportViewResolver extends AbstractPathBasedViewResolver { // Concrete implementation using ES6 imports}Multiple Interface Implementation:
The description-first pattern prevents conflicts when implementing multiple interfaces:
// ✅ Correct: No import conflicts with description-first patternimport { HTTPTransformer, type TransformContext } from '@ndigo/http';import { JSONObject, type Serializable } from '@ndigo/core';
class PaymentProcessor implements HTTPTransformer, Serializable { // Implements both interfaces canTransform(body: JSONObject, _context: TransformContext): boolean { // Applicability check }
transform(body: JSONObject, _context: TransformContext): JSONObject { // Request/response transformation }
toJSON(): JSONObject { // Serialization }}
// ✅ Correct: Multiple interfaces from different packagesimport { ViewResolver } from '@ndigo/view';import { ResourceLoader } from '@ndigo/context';
class TemplateViewResolver implements ViewResolver, ResourceLoader { resolve(name: string): View {} loadResource(path: string): Resource {}}Pattern Summary:
| Pattern | Example | Used For |
|---|---|---|
[Domain][Role] |
HTTPTransformer, ViewResolver |
Interfaces |
[Description][Role] |
UserDataTransformer, PaymentHTTPHandler |
Concrete classes |
Abstract[Description][Role] |
AbstractUserDataTransformer |
Abstract classes |
Rationale:
- Description-first prevents import conflicts - Multiple packages can define
DataTransformerinterface without collision when implementing classes use descriptive prefixes - Abstract prefix signals inheritance - Immediately identifies base classes vs concrete implementations
- Consistent with established enterprise patterns - Follows long-standing conventions (e.g.,
AbstractHandlerMapping,AbstractPathMatchingResourcePatternResolver) - Autocomplete-friendly - Modern IDEs make long names effortless; clarity trumps brevity
3.11. Role-Based Suffixes
Section titled “3.11. Role-Based Suffixes”Pattern: Use semantic suffixes to convey component responsibility
Ndigo uses role-based suffixes aligned with established enterprise conventions to indicate the responsibility and behavior of classes/interfaces. These suffixes provide instant semantic understanding and create a shared vocabulary across the codebase.
Core Suffixes and Their Meanings:
Processing & Handling:
| Suffix | Meaning | Examples |
|---|---|---|
*Handler |
Processes requests, events, or commands; orchestrates handling logic | RouteHandler, ErrorHandler, EventHandler |
*Processor |
Executes specific operations or transformations; processes inputs | EntryProcessor, DestructionAwareProcessor |
*Interceptor |
Intercepts calls to add cross-cutting concerns (logging, auth, etc.) | HTTPInterceptor, ResponseClassHTTPInterceptor |
Adaptation & Translation:
| Suffix | Meaning | Examples |
|---|---|---|
*Adapter |
Adapts one interface to another; translates between protocols | RouteHandlerAdapter, MetadataHandlerAdapter |
*Transformer |
Transforms data structure | DataTransformer, PayloadTransformer |
*Converter |
Converts between types or formats; typically bidirectional | MessageConverter, TypeConverter |
Resolution & Discovery:
| Suffix | Meaning | Examples |
|---|---|---|
*Resolver |
Resolves, determines, or locates values based on criteria | ViewResolver, PropertyResolver, ArgumentResolver |
*Strategy |
Encapsulates algorithm or behavior choice (Strategy pattern) | ContentNegotiationStrategy, HTTPStrategy |
Creation & Lifecycle:
| Suffix | Meaning | Examples |
|---|---|---|
*Factory |
Creates instances or manages object creation lifecycle | Factory, HierarchicalFactory, ScopedFactory |
*Builder |
Constructs complex objects step-by-step (Builder pattern) | QueryBuilder, URLBuilder |
Storage & Coordination:
| Suffix | Meaning | Examples |
|---|---|---|
*Registry |
Stores and retrieves registered objects; acts as catalog | Registry, MappingRegistry |
*Manager |
Manages lifecycle or coordinates multiple components | ContentNegotiationManager, MetadataManager |
*Repository |
Abstracts data access/persistence layer (Repository pattern) | UserRepository, OrderRepository |
Application Layer (MVC):
| Suffix | Meaning | Examples |
|---|---|---|
*Controller |
Handles web requests and routes (MVC pattern) | UserController, ErrorController |
*Service |
Contains business logic and orchestrates operations | UserService, PaymentService |
Usage Examples:
// ✅ Correct: Suffixes convey clear responsibility
// Handler processes requests/eventsinterface ErrorHandler { handle(error: Error): void;}
class RouteErrorHandler implements ErrorHandler { handle(error: Error): void { // Error handling logic }}
// Adapter adapts one interface to anotherinterface RouteHandlerAdapter { handle(route: Route): void;}
class ControllerHandlerAdapter implements RouteHandlerAdapter { handle(route: Route): void { // Adapt controller method to handler }}
// Resolver determines/locates valuesinterface ViewResolver { resolve(name: string): View;}
class ContentNegotiationViewResolver implements ViewResolver { resolve(name: string): View { // Resolve view based on content negotiation }}
// Strategy encapsulates algorithm choiceinterface ContentNegotiationStrategy { negotiate(request: Request): MediaType;}
class PathExtensionContentNegotiationStrategy implements ContentNegotiationStrategy { negotiate(request: Request): MediaType { // Negotiate based on path extension }}
// Factory creates instancesinterface Factory { getInstance(name: string): any;}
class DefaultFactory implements Factory { getInstance(name: string): any { // Factory creation logic }}
// Registry stores/retrieves registered objectsinterface Registry { register(name: string, value: any): void; get(name: string): any;}
class MappingRegistry implements Registry { register(name: string, value: any): void {} get(name: string): any {}}
// Controller handles web requestsclass UserController { @routeMapping({ method: 'GET', path: '/users' }) listUsers(): User[] { // Return list of users }}
// Service contains business logicclass UserService { createUser(data: UserData): User { // Business logic for user creation }
validateUser(user: User): boolean { // Validation logic }}
// ❌ Wrong: Suffix doesn't match actual responsibilityclass UserHandler { // Acts like a Service (contains business logic) validateUser(user: User): boolean {} // Should be UserService}
class DataResolver { // Acts like a Transformer (transforms data) transform(data: RawData): CleanData {} // Should be DataTransformer}
class PaymentProcessor { // Acts like a Service (business logic) processPayment(payment: Payment): boolean {} // Should be PaymentService}Real Examples from Ndigo:
// From @ndigo/dispatcherexport interface RouteHandlerAdapter {}export abstract class AbstractHandlerMethodAdapter implements RouteHandlerAdapter {}export class ControllerHandlerAdapter implements RouteHandlerAdapter {}export class MetadataHandlerAdapter extends AbstractHandlerMethodAdapter {}
export interface HandlerMethodArgumentResolver {}export abstract class AbstractNamedValueMethodArgumentResolver {}export class MatrixValueMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver {}export class QueryValueMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver {}
// From @ndigo/viewexport interface ViewResolver {}export abstract class AbstractCachingViewResolver implements ViewResolver {}export abstract class AbstractPathBasedViewResolver extends AbstractCachingViewResolver {}export class ImportViewResolver extends AbstractPathBasedViewResolver {}export class ContentNegotiationViewResolver implements ViewResolver {}
export interface ContentNegotiationStrategy {}export class PathExtensionContentNegotiationStrategy implements ContentNegotiationStrategy {}export class FixedContentNegotiationStrategy implements ContentNegotiationStrategy {}
// From @ndigo/factoryexport interface Factory {}export interface HierarchicalFactory extends Factory {}export interface ConfigurableFactory extends HierarchicalFactory {}export abstract class AbstractFactory implements ConfigurableFactory {}export class DefaultFactory extends AbstractFactory {}
export interface Registry {}export class MappingRegistry implements Registry {}
// From @ndigo/httpexport interface HTTPInterceptor {}export class MappedHTTPInterceptor implements HTTPInterceptor {}export class MappedResponseClassHTTPInterceptor implements ResponseAwareHTTPInterceptor {}
export class HTTPAppender {}export class HTTPLogRecordExporter {}
// From @ndigo/contextexport interface PropertyResolver {}export interface ConfigurablePropertyResolver extends PropertyResolver {}export class PrototypalPropertyResolver implements ConfigurablePropertyResolver {}
export class ContentNegotiationManager {}export class MetadataManager {}Combining with Description-First Pattern:
Role-based suffixes work seamlessly with the description-first pattern from Section 3.11:
// ✅ Correct: [Description][Role] with semantic suffixclass UserDataTransformer implements DataTransformer {}class PaymentHTTPInterceptor implements HTTPInterceptor {}class OrderViewResolver implements ViewResolver {}class AuthenticationStrategy implements Strategy {}
// ✅ Correct: Abstract classesabstract class AbstractUserFactory implements Factory {}abstract class AbstractHandlerMethodAdapter implements HandlerAdapter {}abstract class AbstractContentNegotiationStrategy implements ContentNegotiationStrategy {}Anti-Patterns to Avoid:
// ❌ Avoid: Generic helper/util suffixes (use specific role suffixes)class DataHelper {} // Use DataTransformer, DataProcessor, or DataServiceclass UserUtil {} // Use UserService, UserFactory, or UserValidatorclass StringHelper {} // Use StringFormatter, StringConverter, or StringParser
// ❌ Avoid: Suffix doesn't match behaviorclass DataHandler { // Actually transforms data transform(data: Data): TransformedData {} // Should be DataTransformer}
class UserResolver { // Actually creates users create(data: UserData): User {} // Should be UserFactory or UserService}Rationale:
- Shared Vocabulary - Consistent suffixes create common understanding across the team
- Instant Recognition - Developers immediately understand a component’s role from its name
- Enterprise-pattern alignment - Leverages established conventions familiar to Java enterprise developers
- Semantic Clarity -
*Handlerprocesses,*Resolverlocates,*Factorycreates—no ambiguity - Autocomplete-Friendly - Modern tooling makes specific, descriptive names effortless
When in Doubt:
- Ask: “What is this component’s PRIMARY responsibility?”
- Choose the suffix that best matches that responsibility
- If no standard suffix fits, consider if the class has too many responsibilities (SRP violation)
3.12. Properties vs Methods
Section titled “3.12. Properties vs Methods”Pattern: Choose based on computation cost, side effects, and semantic meaning
JavaScript/TypeScript allows both properties (with getters) and methods. Ndigo favors properties for cheap, intrinsic data access, and methods for operations that involve external lookups, transformations, or potential failures.
This section provides clear guidelines on when to use each approach.
When to Use Properties
Section titled “When to Use Properties”Use properties when ALL of these conditions are met:
- ✅ O(1) or trivial computation (simple field access, basic concatenation)
- ✅ No side effects (idempotent, safe to call multiple times)
- ✅ No external dependencies (doesn’t query database, registry, file system)
- ✅ Always succeeds (never returns null for legitimate values)
- ✅ Feels like “what this object IS” (intrinsic attribute)
Real Examples from Ndigo:
// From @ndigo/core/media-type.tsclass MediaType { // ✅ Correct: Properties for direct field access readonly type: SupportedMediaType; // Direct field readonly subtype: string; // Direct field readonly parameters: MediaTypeParameters; // Direct field
// ✅ Correct: Properties for cheap computation get isWildcardType(): boolean { // Simple comparison return this.type === '*'; }
get isWildcardSubtype(): boolean { // Simple comparison return this.subtype === '*'; }
get qualityFactor(): number { // Parse from stored parameters return cond([ [isString, parseFloat], [isNumber, identity], [T, alwaysOne], ])(this.parameters['q']) as number; }}
// From @ndigo/http/response-status.tsclass ResponseStatus { // ✅ Correct: Properties for range checks get isInformationClass(): boolean { return 100 <= this.code && this.code <= 199; }
get isSuccessClass(): boolean { return 200 <= this.code && this.code <= 299; }
get isClientErrorClass(): boolean { return 400 <= this.code && this.code <= 499; }}
// From @ndigo/dispatcher/route-condition.tsclass RouteCondition { // ✅ Correct: Properties for simple checks get isEmpty(): boolean { return this.conditions.length === 0; }
get length(): number { return this.conditions.length; }
get content(): RouteCondition[] { return this.conditions; }}
// From @ndigo/core/collection.tsclass BoundedCollection { // ✅ Correct: Properties for collection state get capacity(): number { return this.#capacity; }
get isEmpty(): boolean { return this.length === 0; }
get isFull(): boolean { return this.length >= this.#capacity; }
get length(): number { return this.items.length; }}
// From @ndigo/factory/factory.tsabstract class AbstractFactory { #parent: Factory;
// ✅ Correct: Property for stored reference get parent(): Factory { return this.#parent; }}Counter-Examples (What NOT to Do):
// ❌ Wrong: Method call wrapped in propertyclass BadMediaType { get extensions(): string[] { return this.getExtensions(); // Just call the method directly! }
getExtensions(): string[] { return MIME_DB.get(this.baseType)?.extensions ?? []; }}
// ❌ Wrong: External lookup as property (use get-method)class BadMediaType { get defaultCharset(): string | null { // Queries external MIME database - use getDefaultCharset() method return MIME_DB.get(this.baseType)?.charset ?? null; }}
// ❌ Wrong: Expensive computation as propertyclass BadRoute { get allPossiblePaths(): string[] { // Generates all combinations - expensive! Use method return this.generateAllPermutations(); }}
// ❌ Wrong: get* method for simple field access (use property)class BadExample { #name: string;
getName(): string { // Just use: get name() return this.#name; }}When to Use Get-Methods
Section titled “When to Use Get-Methods”Use get-methods when ANY of these conditions are met:
- ✅ Requires external data (database, registry, file system, network)
- ✅ May fail or return null for legitimate queries
- ✅ Non-trivial computation (O(n), recursive, expensive)
- ✅ Tristate return (true/false/null or success/failure/unknown)
- ✅ Feels like “fetching” or “retrieving”
Real Examples from Ndigo:
// From @ndigo/factory/factory.tsinterface Factory { // ✅ Correct: get* for registry lookup (may fail) getInstance<T = any>(name: string): T;}
interface ConfigurableFactory extends Factory { // ✅ Correct: get* for scope lookup getScope(name: string): Scope; getScopeNames(): string[];}
// From @ndigo/context/property-resolver.tsinterface PropertyResolver { // ✅ Correct: get* for external property lookup getProperty(key: string, defaultValue?: JSONValue): JSONValue; getRequiredProperty(key: string): JSONValue; // May throw}
class PrototypalPropertyResolver implements PropertyResolver { getProperty(key: string, defaultValue?: JSONValue): JSONValue { const parseResult = keypathExpression.run(key); if (!parseResult.isError) { const keypath = parseResult.result; return keypath ? (path(keypath, this.properties) ?? defaultValue) : defaultValue; } return defaultValue; }
getRequiredProperty(key: string): JSONValue { const value = this.getProperty(key); if (typeof value === 'undefined') { throw new Error(`Required property "${key}" missing`); } return value; }}
// From @ndigo/dispatcher/route-handler-mapping.tsinterface RouteHandlerMapping { // ✅ Correct: get* for handler lookup (may return null) getHandler(route: Route): RouteHandlerExecutionChain;}
class AbstractRouteHandlerMapping { getHandler(route: Route): RouteHandlerExecutionChain { // Complex lookup logic with potential null return }
getMappingsByPath(path: string): T[] { // Filters internal registry }
getHandlerMethodsByMappingName(name: string): RouteHandlerMethod[] { // Queries internal mapping registry }}
// From @ndigo/telemetryclass NdigoLoggerProvider { // ✅ Correct: get* for external SDK access getLogger(name: string, version?: string, options?: LoggerOptions): OTelLoggerAPI { // Creates/retrieves logger from external SDK }
getSDKProvider(): SDKLoggerProvider { // Returns external OpenTelemetry SDK provider }
getResource(): Resource { // Returns external OpenTelemetry resource }
getProcessors(): LogRecordProcessor[] { // Returns registered processors }}
// From @ndigo/context/event-logger.tsclass EventLogger { // ✅ Correct: get* that may return undefined getOTelLogger(): OTelLoggerAPI | undefined { // May return undefined if OpenTelemetry not initialized }}
// Proposed for MediaTypeclass MediaType { // ✅ Correct: get* for external MIME database lookup getDefaultCharset(): string | null { // Queries MIME_DB - external data source return MIME_DB.get(this.baseType)?.charset ?? null; }
// ✅ Correct: get* for tristate return getCompressibility(): boolean | null { // Returns true (compressible), false (not), null (unknown) const entry = MIME_DB.get(this.baseType); return entry?.compressible ?? null; }}Why Get-Methods, Not Properties?
// ❌ Wrong: External lookup as propertyget defaultCharset(): string | null { return MIME_DB.get(this.baseType)?.charset ?? null;}
// ✅ Correct: External lookup as methodgetDefaultCharset(): string | null { return MIME_DB.get(this.baseType)?.charset ?? null;}
// Rationale:// 1. Queries external data source (MIME_DB)// 2. Returns null for unknown types (tristate: exists/doesn't exist/unknown)// 3. Semantically feels like "retrieving" not "accessing"// 4. Debuggers won't auto-evaluate methods (avoids side effects during debugging)When to Use To-Methods
Section titled “When to Use To-Methods”Use to-methods when:
- ✅ Transforms to a different format or representation
- ✅ Takes optional parameters to control transformation
- ✅ Creates a new representation (doesn’t just access data)
- ✅ Feels like “converting to X”
Real Examples from Ndigo:
// From @ndigo/core/media-type.tsclass MediaType implements Serializable { // ✅ Correct: to* for string representation toString(): string { const mediaTypeString = this.suffix === null ? `${this.type}/${this.subtype}` : `${this.type}/${this.subtype}+${this.suffix}`; const parameterString = stringifyParameters(this.parameters); return `${mediaTypeString}${parameterString}`; }
// ✅ Correct: to* for JSON serialization toJSON(): JSONValue { return this.toString(); }}
// From @ndigo/http/response-status.tsclass ResponseStatus implements Serializable { toString(): string { return `${this.code} ${this.text}`; }
toJSON(): JSONObject { return { code: this.code, text: this.text, }; }}
// From @ndigo/core/property-value.tsclass PropertyValue { toString(): string { return `PropertyValue(${this.name}=${this.value})`; }}
// Proposed for MediaTypeclass MediaType { // ✅ Correct: to* with options for Content-Type header toContentType(options?: { autoCharset?: boolean }): string { const base = this.toString(); if (options?.autoCharset && !this.parameters.charset) { const charset = this.getDefaultCharset(); return charset ? `${base};charset=${charset}` : base; } return base; }}To-Methods vs Properties:
// ✅ Correct: toString() is a method (standard protocol)toString(): string { return this.serialize();}
// ❌ Wrong: toString as property (breaks ecosystem expectations)get toString(): string { return this.serialize();}
// ✅ Correct: baseType is a property (cheap concat, no parameters)get baseType(): string { return `${this.type}/${this.subtype}`;}
// ✅ Correct: toContentType is a method (takes options)toContentType(options?: ContentTypeOptions): string { // Complex transformation with options}Decision Matrix
Section titled “Decision Matrix”| Use Case | Method Type | Rationale | Example |
|---|---|---|---|
| Stored field access | Property | O(1) lookup, no computation | mediaType.type |
| Simple boolean check | Property | Trivial comparison | mediaType.isWildcardType |
| Cheap concatenation | Property | String template, no side effects | mediaType.baseType |
| Range check | Property | Simple arithmetic | responseStatus.isSuccessClass |
| Array length | Property | Direct field access | collection.length |
| External DB lookup | Get-method | Queries external source | getDefaultCharset() |
| May return null | Get-method | Tristate/optional return | getCompressibility() |
| Registry lookup | Get-method | May fail, external data | factory.getInstance(name) |
| File system access | Get-method | I/O operation | resource.getResource(path) |
| SDK access | Get-method | External library | logger.getSDKProvider() |
| String serialization | To-method | Protocol method (toString) | toString() |
| Format transformation | To-method | Creates new representation | toJSON() |
| Transformation with options | To-method | Parameterized conversion | toContentType(options) |
| Parser invocation | Get-method | May fail, computational | getProperty(key, defaultValue?) |
Edge Cases
Section titled “Edge Cases”Tristate Returns (true/false/null):
// ✅ Correct: Get-method for tristate booleangetCompressibility(): boolean | null { // true = compressible, false = not compressible, null = unknown return MIME_DB.get(this.baseType)?.compressible ?? null;}
// ❌ Wrong: Property for tristate (suggests simple access)get compressibility(): boolean | null { return MIME_DB.get(this.baseType)?.compressible ?? null;}Boolean Properties (is/has prefix):
// ✅ Correct: Boolean properties use is/has prefixget isWildcardType(): boolean { return this.type === '*';}
get hasParameters(): boolean { return Object.keys(this.parameters).length > 0;}
get isEmpty(): boolean { return this.length === 0;}
// From @ndigo/dispatcher/model-and-view.tsget hasView(): boolean { return this.view != null;}Property That Calls Method (Usually Wrong):
// ❌ Anti-pattern: Property wrapping method callget extensions(): string[] { return this.getExtensions(); // Why have both?}
getExtensions(): string[] { return MIME_DB.get(this.baseType)?.extensions ?? [];}
// ✅ Correct: Choose one based on semantics// If it's intrinsic and cheap → property onlyget extensions(): string[] { return MIME_DB.get(this.baseType)?.extensions ?? [];}
// OR if it's external lookup → method onlygetExtensions(): string[] { return MIME_DB.get(this.baseType)?.extensions ?? [];}Getter/Setter Pairs:
// ✅ Correct: Getter/setter for field accessclass PrototypalPropertyResolver { #propertySources: JSONObject[];
get propertySources(): JSONObject[] { return this.#propertySources; }
set propertySources(propertySources: JSONObject[]) { this.#propertySources = propertySources; // Additional logic to update internal state }}
// ❌ Wrong: get* method with set* method for simple fieldclass BadExample { #name: string;
getName(): string { // Should be: get name() return this.#name; }
setName(name: string): void { // Should be: set name() this.#name = name; }}Complete Example: MediaType
Section titled “Complete Example: MediaType”class MediaType { // STORED FIELDS (direct access) readonly type: SupportedMediaType; readonly subtype: string; readonly parameters: MediaTypeParameters;
// PROPERTIES (cheap, intrinsic) get isWildcardType(): boolean { return this.type === '*'; }
get isWildcardSubtype(): boolean { return this.subtype === '*'; }
get qualityFactor(): number { return parseFloat(this.parameters['q'] ?? '1.0'); }
// Proposed: Simple concatenation get baseType(): string { return `${this.type}/${this.subtype}`; }
// Proposed: Static map lookup (O(1)) get extensions(): string[] { return MIME_DB.get(this.baseType)?.extensions ?? []; }
get defaultExtension(): string | null { return this.extensions[0] ?? null; }
// GET-METHODS (external data, may fail) // Proposed: External MIME database lookup getDefaultCharset(): string | null { return MIME_DB.get(this.baseType)?.charset ?? null; }
// Proposed: Tristate return getCompressibility(): boolean | null { const entry = MIME_DB.get(this.baseType); return entry?.compressible ?? null; }
// TO-METHODS (transformation, serialization) toString(): string { const base = this.suffix ? `${this.type}/${this.subtype}+${this.suffix}` : `${this.type}/${this.subtype}`; const params = stringifyParameters(this.parameters); return `${base}${params}`; }
toJSON(): JSONValue { return this.toString(); }
// Proposed: Transformation with options toContentType(options?: { autoCharset?: boolean }): string { const base = this.toString(); if (options?.autoCharset && !this.parameters.charset) { const charset = this.getDefaultCharset(); return charset ? `${base};charset=${charset}` : base; } return base; }}Rationale
Section titled “Rationale”Why This Matters:
- Consistency - Predictable API surface for consumers
- Performance Expectations - Properties signal “cheap,” methods signal “may be expensive”
- Side Effects - Properties should be safe to call anytime, methods may have side effects
- Debugging - Debuggers treat properties and methods differently (auto-evaluation)
- API Design - Clear semantic meaning improves developer experience
Influenced By:
- JavaScript ecosystem conventions (Array.length, String.length, Map.size)
- Java Bean conventions (getters for properties, methods for operations)
- TypeScript guidance (properties for data access, methods for operations)
- Enterprise patterns (get* methods for external lookups)
When in Doubt
Section titled “When in Doubt”Ask yourself:
- Is it cheap? (O(1), no I/O) → Property
- Is it intrinsic? (part of what the object IS) → Property
- Does it query external data? → Get-method
- Can it fail or return null? → Get-method
- Does it transform to another format? → To-method
- Does it take parameters? → Method (get-method or to-method)
If still unsure, prefer a method - it’s safer and more flexible for future changes.
3.13. Method Naming Conventions
Section titled “3.13. Method Naming Conventions”Pattern: Use conventional prefixes to signal method behavior and return expectations
Method names should clearly communicate their purpose, behavior, and expected return values. Ndigo uses standardized prefixes inspired by JavaBeans, Java enterprise, and JavaScript ecosystem conventions.
This section establishes clear rules for method naming across the entire codebase.
Accessor Prefixes
Section titled “Accessor Prefixes”Methods that retrieve or check values without mutation:
| Prefix | Meaning | Returns | Side Effects | May Fail | Examples |
|---|---|---|---|---|---|
get* |
Retrieves/fetches value | Value or null/undef | None | Yes (may be null) | getHandler(), getInstance(), getDefaultCharset() |
is* |
Type/state check | boolean | None | No | isValid(), isString(), isWildcardType() |
has* |
Presence/ownership check | boolean | None | No | hasProperty(), hasView(), hasOwnValue() |
like* |
Duck-typing check (structural | boolean (type guard) | None | No | likeArray(), likePromise(), likeReduced() |
get* - Retrieval/Fetching
Section titled “get* - Retrieval/Fetching”Use when: Retrieving values from external sources, registries, or performing lookups that may fail
// From @ndigo/factory/factory.tsinterface Factory { // ✅ Correct: get* for registry lookup (may return null) getInstance<T = any>(name: string): T;}
interface ConfigurableFactory extends Factory { // ✅ Correct: get* for scope lookup getScope(name: string): Scope; getScopeNames(): string[];}
// From @ndigo/context/property-resolver.tsinterface PropertyResolver { // ✅ Correct: get* for external property lookup getProperty(key: string, defaultValue?: JSONValue): JSONValue; getRequiredProperty(key: string): JSONValue; // May throw}
class PrototypalPropertyResolver implements PropertyResolver { getProperty(key: string, defaultValue?: JSONValue): JSONValue { const parseResult = keypathExpression.run(key); if (!parseResult.isError) { const keypath = parseResult.result; return keypath ? (path(keypath, this.properties) ?? defaultValue) : defaultValue; } return defaultValue; }
getRequiredProperty(key: string): JSONValue { const value = this.getProperty(key); if (typeof value === 'undefined') { throw new Error(`Required property "${key}" missing`); } return value; }}
// From @ndigo/dispatcher/route-handler-mapping.tsclass AbstractRouteHandlerMapping { // ✅ Correct: get* for complex lookups getHandler(route: Route): RouteHandlerExecutionChain { // Complex lookup logic with potential null return }
getMappingsByPath(path: string): T[] { // Filters internal registry }
getHandlerMethodsByMappingName(name: string): RouteHandlerMethod[] { // Queries internal mapping registry }
getHandlerMethods(): Map<T, RouteHandlerMethod> { // Returns all registered handlers }}
// From @ndigo/telemetryclass NdigoLoggerProvider { // ✅ Correct: get* for external SDK access getLogger(name: string, version?: string, options?: LoggerOptions): OTelLoggerAPI { // Creates/retrieves logger from external SDK }
getSDKProvider(): SDKLoggerProvider { // Returns external OpenTelemetry SDK provider }
getResource(): Resource { // Returns external OpenTelemetry resource }
getProcessors(): LogRecordProcessor[] { // Returns registered processors }}
// From @ndigo/context/event-logger.tsclass EventLogger { // ✅ Correct: get* that may return undefined getOTelLogger(): OTelLoggerAPI | undefined { // May return undefined if OpenTelemetry not initialized }}
// From @ndigo/view/content-negotiation-view-resolver.tsclass ContentNegotiationViewResolver { // ✅ Correct: get* for collecting from multiple sources getAllFileExtenstions(): string[] { // Aggregates extensions from all resolvers }
getMediaTypes(request: any): MediaType[] { // Extracts media types from request }}
// Proposed for MediaTypeclass MediaType { // ✅ Correct: get* for external MIME database lookup getDefaultCharset(): string | null { return MIME_DB.get(this.baseType)?.charset ?? null; }
getCompressibility(): boolean | null { const entry = MIME_DB.get(this.baseType); return entry?.compressible ?? null; }}
// ❌ Wrong: get* for simple field access (use property)class BadExample { #name: string;
getName(): string { // Just use: get name() return this.#name; }}is* - Type/State Checks
Section titled “is* - Type/State Checks”Use when: Checking types with typeof/instanceof or checking boolean state
// From @ndigo/util/types.ts// ✅ Correct: is* for type checksfunction isString(value: unknown): value is string { return typeof value === 'string';}
function isNumber(value: unknown): value is number { return typeof value === 'number' && !Number.isNaN(value);}
function isConstructor(value: unknown): value is Constructor { return typeof value === 'function' && value.prototype !== undefined;}
function isFunction(value: unknown): value is Function { return typeof value === 'function';}
function isPrimitive(value: unknown): boolean { return value == null || typeof value !== 'object';}
// isValidDate-style predicates live in @ndigo/util (date utilities)function isValidDate(value: temporal, format: string): boolean { return !isNaN(Date.parse(value));}
// ✅ Correct: is* for boolean state checks (as properties)class MediaType { get isWildcardType(): boolean { return this.type === '*'; }
get isWildcardSubtype(): boolean { return this.subtype === '*'; }}
class ResponseStatus { get isInformationClass(): boolean { return this.code >= 100 && this.code < 200; }
get isSuccessClass(): boolean { return this.code >= 200 && this.code < 300; }
get isClientErrorClass(): boolean { return this.code >= 400 && this.code < 500; }}
// ❌ Wrong: is* that returns null (use get*)function isCompressible(mediaType: MediaType): boolean | null { // Should be: getCompressibility() return mediaType.getCompressibility();}has* - Presence/Ownership Checks
Section titled “has* - Presence/Ownership Checks”Use when: Checking for presence of properties, items, or capabilities
// From @ndigo/context/property-resolver.tsinterface PropertyResolver { // ✅ Correct: has* for presence check hasProperty(key: string): boolean;}
class PrototypalPropertyResolver implements PropertyResolver { hasProperty(key: string): boolean { const parseResult = keypathExpression.run(key); if (!parseResult.isError) { const keypath = parseResult.result; return keypath ? pathIsDefined(keypath, this.properties) : false; } return false; }}
// From @ndigo/factory/factory.tsinterface HierarchicalFactory extends Factory { // ✅ Correct: has* for ownership check hasOwnValue(name: string): boolean;}
// From @ndigo/dispatcher/model-and-view.tsclass ModelAndView { // ✅ Correct: has* as property for simple check get hasView(): boolean { return this.view != null; }}
// From @ndigo/core/media-type.ts (proposed)class MediaType { // ✅ Correct: has* for parameter presence get hasParameters(): boolean { return Object.keys(this.parameters).length > 0; }
hasParameter(name: string): boolean { return name in this.parameters; }}
// Standard JavaScript examplesclass Map<K, V> { has(key: K): boolean; // Built-in JavaScript}
class Set<T> { has(value: T): boolean; // Built-in JavaScript}like* - Duck-Typing Checks
Section titled “like* - Duck-Typing Checks”Use when: Checking structural compatibility without typeof/instanceof
// From @ndigo/core// ✅ Correct: like* for duck typingfunction likeArray(value: unknown): value is ArrayLike<any> { return value != null && typeof value === 'object' && 'length' in value;}
function likePromise(value: unknown): value is PromiseLike<any> { return value != null && typeof (value as any).then === 'function';}
// From @ndigo/dispatcherfunction likeController(value: unknown): value is ControllerLike { return ( value != null && typeof value === 'object' && 'handle' in value && typeof (value as any).handle === 'function' );}
// From @ndigo/factoryconst likeFactory = like({ getInstance: Function }) as (value) => value is Factory;
const likeFactoryAware = likeAwareAnd(like({ setFactory: Function })) as (value) => value is FactoryAware;
const likeHierarchicalFactory = both(likeFactory, like({ parent: true, hasOwnValue: Function }));
// From @ndigo/httpfunction likeResponse(value: unknown): value is Response { return value != null && typeof value === 'object' && 'status' in value && 'statusText' in value;}Mutation Prefixes
Section titled “Mutation Prefixes”Methods that modify state:
| Prefix | Meaning | Returns | Side Effects | Examples |
|---|---|---|---|---|
set* |
Sets value | void or this | Mutates object | setProperty(), setFactory(), setParent() |
add* |
Adds to collection | void or this | Mutates | add(), addProcessor(), addListener() |
remove* |
Removes from collection | boolean / this | Mutates | remove(), removeListener() |
clear* |
Empties collection | void | Mutates | clear(), clearCache() |
update* |
Modifies existing value | void or this | Mutates | updateProperty(), updateStatus() |
delete* |
Removes/destroys | boolean | Mutates | delete(), deleteEntry() |
Examples:
// From @ndigo/factory/aware.tsinterface FactoryAware extends Aware { // ✅ Correct: set* for mutating reference setFactory(factory: Factory): void;}
// From @ndigo/factory/factory.tsinterface ConfigurableFactory extends Factory { // ✅ Correct: set* for parent assignment setParent(parent: Factory): void;
// ✅ Correct: add* for collection mutation addProcessor(processor: EntryProcessor): void;}
abstract class AbstractFactory implements ConfigurableFactory { #parent: Factory;
setParent(parent: Factory): void { this.#parent = parent; }
addProcessor(processor: EntryProcessor): void { const index = this.processors.indexOf(processor); if (index !== -1) { this.processors.splice(index, 1); } this.processors.push(processor); }}
// From @ndigo/core/data-binder.tsclass DataBinder { #requiredFields: Set<string>;
// ✅ Correct: Getter/setter pair for field with validation get requiredFields(): string[] { if (!this.#requiredFields) { this.#requiredFields = new Set<string>(); } return [...this.#requiredFields]; }
set requiredFields(fields: string[]) { this.#requiredFields = new Set<string>(); if (fields?.length) { for (let index = 0; index < fields.length; index++) { this.#requiredFields.add(fields[index]); } } }}
// From @ndigo/factory/registry.tsinterface Registry { // ✅ Correct: Mutation methods set(name: string, value: any): void; delete(name: string): boolean; clear(): void;}
// ✅ Correct: Fluent API (return this)class Builder { #name: string; #properties: Map<string, any>;
setName(name: string): this { this.#name = name; return this; }
addProperty(key: string, value: any): this { this.#properties.set(key, value); return this; }
build(): Product { return new Product(this.#name, this.#properties); }}
// ❌ Wrong: set* that doesn't mutate (use with*)class BadMediaType { setCharset(charset: string): MediaType { // Returns new instance - should be withCharset() return new MediaType(this.type, this.subtype, { charset }); }}
// ✅ Correct: with* for immutable updatesclass MediaType { withCharset(charset: string): MediaType { return new MediaType(this.type, this.subtype, { ...this.parameters, charset, }); }}Factory/Creation Prefixes
Section titled “Factory/Creation Prefixes”Methods that create new instances:
| Prefix | Meaning | Returns | Parameters | Examples |
|---|---|---|---|---|
create* |
Creates new instance | New object | Configuration | create(), createBlob(), createLogger() |
from* |
Parses/converts from input | New object or null | Source data | fromExtension(), fromRequest(), from() |
parse* |
Parses string to object | Object or throws | String input | parse(), parseInt(), parseFloat() |
of* |
Factory with varargs | New object | Variable args | of(), Array.of(), Promise.of() |
make* |
Alternative to create | New object | Configuration | make(), makeHandler() |
Examples:
// From @ndigo/core/media-type.tsclass MediaType { // ✅ Correct: Static factory method static for(mediaType: string): MediaType; static for(type: SupportedMediaType, subtype: string, parameters?: MediaTypeParameters): MediaType; static for( typeOrExpression: SupportedMediaType | string, subtype?: string, parameters?: MediaTypeParameters ): MediaType { // Factory logic with singleton registry }
// ✅ Correct: create* for instance creation createBlob(data?: BlobPart[], endings: EndingType = 'transparent'): Blob { return new Blob(data, { type: this.toString(), endings: endings, }); }}
// Proposed for MediaTypeclass MediaType { // ✅ Correct: from* for conversions/parsing (may return null) static fromExtension(ext: string): MediaType | null { const entry = MIME_DB.findByExtension(ext); return entry ? MediaType.for(entry.type) : null; }
static fromFilePath(path: string): MediaType | null { const ext = path.split('.').pop(); return this.fromExtension(ext); }
static fromRequest(request: Request): MediaType | null { const contentType = request.headers.get('Content-Type'); return contentType ? MediaType.for(contentType) : null; }
static fromResponse(response: Response): MediaType | null { const contentType = response.headers.get('Content-Type'); return contentType ? MediaType.for(contentType) : null; }}
// From @ndigo/http/response-status.tsclass ResponseStatus { // ✅ Correct: Static factory (overloaded) static for(response: Response): ResponseStatus; static for(code: number, text?: string): ResponseStatus; static for(codeOrResponse: number | Response, text?: string): ResponseStatus { // Factory with singleton registry }}
// Native — Object.fromEntries replaces any pair-building helperconst pairs: [string, number][] = [ ['a', 1], ['b', 2],];const obj = Object.fromEntries(pairs); // { a: 1, b: 2 }
// ✅ Correct: parse* for string parsing (may throw)class MediaType { static parse(mediaTypeString: string): MediaType { if (!mediaTypeString) { throw new Error('Invalid media type'); } return MediaType.for(mediaTypeString); }}
// Standard JavaScript examplesconst num = parseInt('42', 10);const float = parseFloat('3.14');const date = Date.parse('2025-01-01');const json = JSON.parse('{"key":"value"}');
// ✅ Correct: of* for varargs factoriesclass MediaType { static of(type: string, subtype: string, ...parameters: [string, string][]): MediaType { const params = fromPairs(parameters); return MediaType.for(type, subtype, params); }}
// Standard JavaScriptconst arr = Array.of(1, 2, 3); // [1, 2, 3]const promise = Promise.resolve(42);Transformation Prefixes
Section titled “Transformation Prefixes”Methods that convert between formats:
| Prefix | Meaning | Returns | Parameters | Examples |
|---|---|---|---|---|
to* |
Converts to format | String or object | Often accepts opts | toString(), toJSON(), toContentType() |
format* |
Formats to string | String | May accept format | format(), formatDate() |
convert* |
Bidirectional conversion | Converted value | None | convert(), convertToUTC() |
with* |
Creates copy with change | New instance (immut) | New value | withCharset(), withParameter() |
parse* |
Deserializes a string into a structured value; returns null or throws on invalid input |
Typed value or null |
String input | parseKeypath(), parseWildcard(), JSON.parse() |
stringify* |
Serializes a value to its string representation | String | Value to serialize | JSON.stringify(), stringifyParameters() |
Examples:
// From @ndigo/core/media-type.tsclass MediaType implements Serializable { // ✅ Correct: to* for string representation toString(): string { const mediaTypeString = this.suffix === null ? `${this.type}/${this.subtype}` : `${this.type}/${this.subtype}+${this.suffix}`; const parameterString = stringifyParameters(this.parameters); return `${mediaTypeString}${parameterString}`; }
// ✅ Correct: to* for JSON serialization toJSON(): JSONValue { return this.toString(); }
// ✅ Correct: with* for immutable updates withCharset(charset: string): MediaType { return new MediaType(this.type, this.subtype, { ...this.parameters, charset, }); }}
// Proposed for MediaTypeclass MediaType { // ✅ Correct: to* with options for transformation toContentType(options?: { autoCharset?: boolean }): string { const base = this.toString(); if (options?.autoCharset && !this.parameters.charset) { const charset = this.getDefaultCharset(); return charset ? `${base};charset=${charset}` : base; } return base; }
// ✅ Correct: with* for immutable parameter updates withParameter(key: string, value: string): MediaType { return new MediaType(this.type, this.subtype, { ...this.parameters, [key]: value, }); }}
// From @ndigo/http/response-status.tsclass ResponseStatus implements Serializable { toString(): string { return `${this.code} ${this.text}`; }
toJSON(): JSONObject { return { code: this.code, text: this.text, }; }}
// From @ndigo/core/property-value.tsclass PropertyValue { toString(): string { return `PropertyValue(${this.name}=${this.value})`; }}
// From @ndigo/core — parse* for string deserialization// parse* functions return the result directly or null on failure (never a discriminated union)function parseWildcard(pattern: string): string { // Always succeeds — any string is a valid wildcard return `^${pattern.replace(/\*/g, '.*?')}$`;}
function parseKeypath(input: string): KeypathToken[] | null { // Returns null for invalid input rather than throwing // (callers treat an unparseable keypath as "no match" rather than an error) // ...}
// ❌ Wrong: parse* that returns a discriminated union (use T | null instead)// function parseKeypath(input: string): { isError: false; result: KeypathToken[] } | { isError: true; error: string }// Unnecessarily complex — callers check `if (keypath)` not `if (!result.isError)`
// ✅ parse* functions align with the Converter<string, T> type alias from @ndigo/coreimport type { Converter } from '@ndigo/core';const parseWildcard: Converter<string, string> = (pattern) => `^${pattern.replace(/\*/g, '.*?')}$`;
// Real JavaScript examplesconst arr = [1, 2, 3];arr.toString(); // "1,2,3"
const obj = { name: 'Alice' };JSON.stringify(obj); // '{"name":"Alice"}'
const date = new Date();date.toISOString(); // "2025-02-07T12:00:00.000Z"date.toLocaleDateString(); // "2/7/2025"
// ❌ Wrong: with* that mutates (use set*)class BadBuilder { withName(name: string): this { this.#name = name; // Mutates! Should be setName() return this; }}Special Patterns
Section titled “Special Patterns”Async Methods
Section titled “Async Methods”Pattern 1: Prefix with async (preferred for new code)
// ✅ Correct: async prefix for async operationsasync function asyncLoadResource(path: string): Promise<Resource> { const response = await fetch(path); return response.json();}
class ResourceLoader { async asyncLoad(path: string): Promise<Resource> { return await fetch(path).then((r) => r.json()); }}Pattern 2: Return Promise (acceptable in established code)
// ✅ Acceptable: Async implied by Promise returnclass ResourceLoader { load(path: string): Promise<Resource> { return fetch(path).then((r) => r.json()); }}
// From @ndigo/context/resource.tsinterface ResourceLoader { getResource(location: string): Promise<Resource>;}Stream/Observable Methods
Section titled “Stream/Observable Methods”Pattern: *Async suffix or return Observable
// ✅ Correct: *Async suffix for streamsclass EventStream { watchAsync(): Observable<Event> { return new Observable((observer) => { // Stream events }); }}
// ✅ Acceptable: Implied by return typeclass EventStream { watch(): Observable<Event> { return new Observable((observer) => { // Stream events }); }}Predicate Methods
Section titled “Predicate Methods”Pattern: Always return boolean (or type guard)
// ✅ Correct: Predicates return booleanfunction isValid(value: unknown): boolean { return value != null;}
// ✅ Correct: Type guard predicatesfunction isString(value: unknown): value is string { return typeof value === 'string';}
// ❌ Wrong: Predicate returns tristatefunction isCompressible(type: MediaType): boolean | null { // Should be: getCompressibility() return type.getCompressibility();}Mutator Methods
Section titled “Mutator Methods”Pattern: Return void or this (for chaining)
// ✅ Correct: Mutator returns voidclass Registry { set(name: string, value: any): void { this.entries.set(name, value); }
clear(): void { this.entries.clear(); }}
// ✅ Correct: Mutator returns this (fluent API)class Builder { #name: string; #properties: Map<string, any>;
setName(name: string): this { this.#name = name; return this; }
addProperty(key: string, value: any): this { this.#properties.set(key, value); return this; }
build(): Product { return new Product(this.#name, this.#properties); }}
// Usageconst product = new Builder().setName('Widget').addProperty('color', 'blue').addProperty('size', 'large').build();Anti-Patterns
Section titled “Anti-Patterns”get* for Simple Field Access
Section titled “get* for Simple Field Access”// ❌ Wrong: get* method for simple field accessclass BadExample { #name: string;
getName(): string { return this.#name; }}
// ✅ Correct: Property for simple field accessclass GoodExample { #name: string;
get name(): string { return this.#name; }}set* That Doesn’t Mutate
Section titled “set* That Doesn’t Mutate”// ❌ Wrong: set* that returns new instanceclass BadMediaType { setCharset(charset: string): MediaType { // Returns new instance - not mutating! return new MediaType(this.type, this.subtype, { charset }); }}
// ✅ Correct: with* for immutable updatesclass GoodMediaType { withCharset(charset: string): MediaType { return new MediaType(this.type, this.subtype, { ...this.parameters, charset, }); }}is* That Returns Null
Section titled “is* That Returns Null”// ❌ Wrong: is* returns tristatefunction isCompressible(type: MediaType): boolean | null { return type.getCompressibility();}
// ✅ Correct: get* for tristate returnsfunction getCompressibility(type: MediaType): boolean | null { return MIME_DB.get(type.baseType)?.compressible ?? null;}to* That Doesn’t Transform
Section titled “to* That Doesn’t Transform”// ❌ Wrong: to* that just accesses dataclass BadMediaType { toBaseType(): string { // Just accessing/concatenating - should be property return `${this.type}/${this.subtype}`; }}
// ✅ Correct: Property for simple accessclass GoodMediaType { get baseType(): string { return `${this.type}/${this.subtype}`; }}Complete Reference Table
Section titled “Complete Reference Table”| Prefix | Use Case | Returns | Side Effects | May Fail | Example |
|---|---|---|---|---|---|
get* |
Retrieve from external | Value or null | None | Yes | getHandler(route) |
is* |
Type/state check | boolean | None | No | isWildcardType() |
has* |
Presence check | boolean | None | No | hasProperty(key) |
like* |
Duck-typing check | boolean (type guard) | None | No | likeArray(value) |
set* |
Mutate value | void or this | Mutates | No | setProperty(k, v) |
add* |
Add to collection | void or this | Mutates | No | addProcessor(p) |
remove* |
Remove from collection | boolean or this | Mutates | No | removeListener(fn) |
clear* |
Empty collection | void | Mutates | No | clearCache() |
update* |
Modify existing | void or this | Mutates | No | updateStatus(s) |
delete* |
Remove/destroy | boolean | Mutates | No | deleteEntry(k) |
create* |
Create instance | New object | None | No | createBlob(data) |
from* |
Parse/convert from | Object or null | None | Yes | fromExtension(ext) |
parse* |
Parse string | Object or throws | None | Yes | parse(str) |
of* |
Varargs factory | New object | None | No | of(...args) |
to* |
Transform to format | String/object | None | No | toString() |
format* |
Format to string | String | None | No | formatDate(d) |
with* |
Immutable update | New instance | None | No | withCharset(cs) |
Rationale
Section titled “Rationale”Why This Matters:
- Predictability - Developers instantly understand method behavior from name
- Consistency - Same patterns across entire codebase
- API Design - Clear contracts between callers and implementations
- Ecosystem Alignment - Follows JavaScript/TypeScript/Java conventions
- Tooling Support - IDEs can provide better autocomplete and suggestions
Influenced By:
- JavaBeans naming conventions (get*, set*, is*)
- Enterprise patterns (get* for external lookups)
- JavaScript standard library (Array.from, Array.of, toString, toJSON)
- TypeScript type guard conventions (is* predicates)
- Functional programming (from*, to* transformations)
- Immutable data structures (with* for copies)
When in Doubt
Section titled “When in Doubt”Ask yourself:
- Does it retrieve from external source? →
get* - Does it check type or state? →
is*orhas* - Does it mutate the object? →
set*,add*,remove* - Does it create new instance? →
create*,from*,of* - Does it transform to different format? →
to*,format* - Does it return new immutable copy? →
with*
If still unsure, check similar methods in the codebase for established patterns.
3.14. Type-Safe Enums
Section titled “3.14. Type-Safe Enums”Pattern: PascalCase for class names, UPPER_SNAKE_CASE for enum constants
Type-safe enums use classes to create enumeration constants with enhanced capabilities beyond built-in TypeScript enums. This pattern provides methods, inheritance, factory functions, and full serialization control.
See also: Type-Safe Enums — the pattern’s home in this guide.
Class Naming
Section titled “Class Naming”Type-safe enum classes follow standard class naming conventions:
// ✅ Correct: PascalCase for enum classexport class MethodParameterType { /* ... */}export class Level { /* ... */}export class SyslogLevel extends Level { /* ... */}export class ResponseStatus { /* ... */}
// ❌ Wrong: Other casingsexport class methodParameterType { /* ... */} // Wrong: camelCaseexport class method_parameter_type { /* ... */} // Wrong: snake_caseexport class METHODPARAMETERTYPE { /* ... */} // Wrong: all capsRationale: Enum classes are classes and should follow standard PascalCase convention.
Static Constant Naming
Section titled “Static Constant Naming”Enum constant values use UPPER_SNAKE_CASE to match built-in enum conventions:
// ✅ Correct: UPPER_SNAKE_CASE for enum constantsclass Level { static OFF = new Level('off', 0); static ALL = new Level('all', Number.MAX_VALUE);}
class MethodParameterType { static COOKIE = new MethodParameterType('cookie:value'); static MATRIX = new MethodParameterType('matrix:value'); static QUERY = new MethodParameterType('query:value');}
class ResponseStatus { static OK = ResponseStatus.for(200, 'OK'); static NOT_FOUND = ResponseStatus.for(404, 'Not Found'); static INTERNAL_SERVER_ERROR = ResponseStatus.for(500, 'Internal Server Error');}
// ❌ Wrong: Other casingsstatic cookie = new MethodParameterType('cookie'); // Wrong: camelCasestatic Cookie = new MethodParameterType('cookie'); // Wrong: PascalCasestatic COOKIE_VALUE = new MethodParameterType('cookie'); // Redundant suffixRationale: Matches built-in enum constant naming and clearly distinguishes constants from methods.
Implementation Patterns
Section titled “Implementation Patterns”Inline Assignment (Recommended):
Modern ES2022+ style with inline static initialization:
export class Level implements Comparable<Level>, Serializable { static OFF = new Level('off', 0); static ALL = new Level('all', Number.MAX_VALUE);
readonly name: string; readonly value: number;
get [Symbol.toStringTag](): string { return `Level:${this.name}`; }
constructor(name: string, value: number) { Reflect.defineProperty(this, 'name', { value: name, writable: false, enumerable: false, configurable: true, }); Reflect.defineProperty(this, 'value', { value: value, writable: false, enumerable: false, configurable: true, }); }
compareTo(other: Level): number { return this.value - other.value; }
toJSON(): string { return this.name; }
toString(): string { return this.name; }}Post-Class Assignment (Alternative):
Traditional style with forward declaration and post-class initialization:
export class MethodParameterType implements Equatable, Serializable { static COOKIE: MethodParameterType; static QUERY: MethodParameterType;
readonly type: string;
get [Symbol.toStringTag](): string { return `MethodParameterType:${this.type}`; }
constructor(type: string) { Reflect.defineProperty(this, 'type', { value: type, writable: false, enumerable: false, configurable: true, }); }
equals(other: any): boolean { return other instanceof MethodParameterType && other.type === this.type; }
toJSON(): string { return this.type; }
toString(): string { return this.type; }}
// Post-class assignmentMethodParameterType.COOKIE = new MethodParameterType('cookie:value');MethodParameterType.QUERY = new MethodParameterType('query:value');Preference: Use inline assignment for new code. Both styles are acceptable.
Required Methods
Section titled “Required Methods”All type-safe enums should implement these core methods:
Equality Check (Equatable):
equals(other: any): boolean { return other instanceof EnumType && other.value === this.value;}Serialization (Serializable):
toJSON(): string | JSONObject { return this.value; // Simple string, or structured object}
toString(): string { return this.value;}Debug String:
get [Symbol.toStringTag](): string { return `EnumType:${this.value}`;}Ordering (Comparable - Optional):
compareTo(other: EnumType): number { return this.numericValue - other.numericValue;}Property Immutability
Section titled “Property Immutability”Use Reflect.defineProperty to ensure true immutability:
constructor(value: string) { Reflect.defineProperty(this, 'value', { value: value, writable: false, // Immutable - cannot be changed enumerable: false, // Hidden from JSON.stringify loops configurable: true, // Allows property descriptor changes });}Why not readonly?
The readonly keyword only provides compile-time protection. Reflect.defineProperty with writable: false provides runtime immutability, preventing accidental modification even through reflection or type casting.
Rationale: Enum constants must be truly immutable to maintain singleton identity and type safety.
Factory Method Naming
Section titled “Factory Method Naming”Static factory methods follow these naming conventions:
Lookup Methods:
// ✅ Correct: from* prefix for parsing/lookupstatic fromType(type: string): EnumType | null { /* ... */ }static fromValue(value: number): EnumType | null { /* ... */ }static fromString(str: string): EnumType | null { /* ... */ }
// ✅ Correct: for* prefix for factory with cachingstatic for(code: number, reason: string): ResponseStatus { /* ... */ }
// ❌ Wrong: Inconsistent namingstatic getByType(type: string) { /* ... */ } // Use fromTypestatic parse(str: string) { /* ... */ } // Use fromStringConversion Methods:
// ✅ Correct: to* prefix for conversionstatic toSeverity(level: Level): SeverityNumber { /* ... */ }static toOrdinal(value: EnumType): number { /* ... */ }
// ✅ Correct: from* prefix for reverse conversionstatic fromSeverity(severity: SeverityNumber): Level { /* ... */ }static fromOrdinal(ordinal: number): EnumType { /* ... */ }Rationale: Follows established convention from STYLE_GUIDE.md Section 3.13 (Method Naming).
Factory Function Naming
Section titled “Factory Function Naming”Standalone factory functions (not static class methods) follow these naming conventions:
Enforcement: the internal/no-global-state-in-create-factories rule automates
the mechanically-checkable slice — create* factories (standalone, or static
methods) that reference mutable module-level (let/var) or class-level
(non-readonly static) state directly. Transitive dependencies reached through
helper calls, and ambient globals (process.env, globalThis), remain review
checks.
Creation Functions (no global state):
// ✅ Correct: create* for pure creation (no global configuration)export const createShellContext = (options: ShellContextOptions) => { /* ... */};export const createFeatureContext = (parent: ApplicationContext, options: FeatureContextOptions) => { /* ... */};export const createStringSplitConverter = (separator: string | RegExp) => { /* ... */};export const createOptionalConverter = (converter: ArgumentValueConverter) => { /* ... */};export const createNull = () => Object.create(null);export const createDataSource = (options: DataSourceOptions) => { /* ... */};export const createUnitOfWork = (entityManager: EntityManager) => { /* ... */};
// ❌ Wrong: get* for pure creation (use create*)export const getShellContext = (options: ShellContextOptions) => { /* ... */}; // Use createShellContextexport const getStringSplitConverter = (separator: string) => { /* ... */}; // Use createStringSplitConverterConfiguration-Dependent Functions (with global state):
// ✅ Correct: get* when global state affects the resultclass LoggerFactory { private static type: LoggerType = 'console'; private static threshold: SeverityNumber = SeverityNumber.DEBUG; private static contextAware: boolean = true;
public static getLogger(name: string): Logger { // Creates a logger with global configuration applied }}
// ❌ Wrong: create* when global state is involved (use get*)class LoggerFactory { public static createLogger(name: string): Logger { /* ... */ } // Use getLogger}Decision Rule:
| Pattern | When to use | Example |
|---|---|---|
create*() |
No global state, per-instance creation | createShellContext(), createRouter() |
get*() |
Global state affects the returned instance | LoggerFactory.getLogger() |
Rationale: create*() signals “a new instance is created.” get*() signals “an instance is returned based on current configuration.” This distinction helps callers understand whether the factory has side effects or depends on shared state.
Maintenance Note: When updating this section, also update AGENTS.md Critical section (Section 3.13) to ensure agents follow the pattern.
Interface Implementation
Section titled “Interface Implementation”Type-safe enums typically implement one or more standard interfaces:
Basic Enum (Equatable + Serializable):
export class SimpleEnum implements Equatable, Serializable { static VALUE_ONE = new SimpleEnum('one'); static VALUE_TWO = new SimpleEnum('two');
readonly value: string;
constructor(value: string) { /* ... */ }
equals(other: any): boolean { return other instanceof SimpleEnum && other.value === this.value; }
toJSON(): string { return this.value; }
toString(): string { return this.value; }}Ordered Enum (Comparable + Serializable):
export class OrderedEnum implements Comparable<OrderedEnum>, Serializable { static LOW = new OrderedEnum('low', 1); static MEDIUM = new OrderedEnum('medium', 2); static HIGH = new OrderedEnum('high', 3);
readonly name: string; readonly priority: number;
constructor(name: string, priority: number) { /* ... */ }
compareTo(other: OrderedEnum): number { return this.priority - other.priority; }
toJSON(): string { return this.name; }
toString(): string { return this.name; }}Usage Examples
Section titled “Usage Examples”Identity Checks (Reference Equality):
// ✅ Correct: Reference equality for identityif (paramType === MethodParameterType.COOKIE) { // Process cookie parameter}
// ✅ Correct: Value equality for comparisonif (paramType.equals(otherType)) { // Types are semantically equal}
// ❌ Wrong: String comparison (fragile, bypasses type safety)if (paramType.toString() === 'cookie:value') { // Don't do this - loses type safety}Ordering:
// ✅ Correct: Use compareTo() for orderingconst levels = [Level.ERROR, Level.INFO, Level.DEBUG];levels.sort((a, b) => a.compareTo(b));
if (currentLevel.compareTo(Level.ERROR) <= 0) { // Critical or error level}
// ❌ Wrong: Direct comparison operatorsif (level1 > level2) { // Won't work - compares references}Serialization:
// ✅ Correct: Automatic serialization via toJSON()const config = { level: Level.INFO, paramType: MethodParameterType.QUERY,};const json = JSON.stringify(config);// Result: {"level":"info","paramType":"query:value"}
// ✅ Correct: Manual string conversionconst levelString = level.toString(); // "info"Factory Method Usage:
// ✅ Correct: Parse external input to enum constantconst paramType = MethodParameterType.fromType(request.paramType);if (paramType === null) { throw new Error(`Unknown parameter type: ${request.paramType}`);}
// ✅ Correct: Type-safe comparison after parsingif (paramType === MethodParameterType.COOKIE) { // Handle cookie parameter}Inheritance
Section titled “Inheritance”Type-safe enums support inheritance for shared behavior:
// Base enum classexport class Level implements Comparable<Level>, Serializable { static OFF = new Level('off', 0); static ALL = new Level('all', Number.MAX_VALUE);
readonly name: string; readonly value: number;
constructor(name: string, value: number) { /* ... */ }
// Shared behavior compareTo(other: Level): number { return this.value - other.value; }
toJSON(): string { return this.name; }
toString(): string { return this.name; }}
// Specialized subclassexport class SyslogLevel extends Level { static EMERG = new Level('emerg', 10000); static ERROR = new Level('error', 40000); // ... more constants
// Additional conversion methods static toSeverity(level: Level): SeverityNumber { /* ... */ } static fromSeverity(severity: SeverityNumber): Level { /* ... */ }}Use Case: Logging level hierarchies with different standard sets (Syslog, CLI, NPM, Apache) sharing comparison logic.
When to Use Type-Safe Enums vs Built-in Enums
Section titled “When to Use Type-Safe Enums vs Built-in Enums”Use Type-Safe Enum Pattern When:
- ✅ Need instance methods (equals, compareTo, custom logic)
- ✅ Need factory methods (fromType, conversion methods)
- ✅ Rich data model (multiple properties per constant)
- ✅ Inheritance hierarchies needed
- ✅ Part of public API (predictable serialization)
- ✅ Large value sets (8+ values)
Use Built-in TypeScript Enum When:
- ✅ Simple constants (no behavior)
- ✅ Internal use only (not in public API)
- ✅ Small value sets (2-5 values)
- ✅ No methods or logic needed
See: the examples below for the full contract.
Documentation
Section titled “Documentation”Always document enum constants with JSDoc:
export class HttpMethod implements Equatable, Serializable { /** * HTTP GET method - retrieves resources without side effects. * Safe and idempotent. */ static GET = new HttpMethod('GET', true, true);
/** * HTTP POST method - creates new resources. * Not safe, not idempotent. */ static POST = new HttpMethod('POST', false, false);
/** * HTTP PUT method - updates existing resources. * Not safe, but idempotent. */ static PUT = new HttpMethod('PUT', false, true);
readonly name: string; readonly safe: boolean; readonly idempotent: boolean;
constructor(name: string, safe: boolean, idempotent: boolean) { /* ... */ }}Examples from Codebase
Section titled “Examples from Codebase”Simple Type-Safe Enum:
MethodParameterType-@ndigo/dispatcher/src/parameter-types.ts
Factory Registry Pattern:
ResponseStatus-@ndigo/http/src/response-status.ts
Built-in Enum (Appropriate Use):
RouteAccess,RouteSource-@ndigo/router/src/hash-router.ts
3.15. Validation
Section titled “3.15. Validation”Pattern: Zod is the single runtime-schema primitive. There is exactly one way to validate a value at each level — no parallel constraint/decorator APIs. (ndigo-validate and ndigo-model/objectmodel were retired 2026-09-05; do not re-introduce them.)
Pick the shape by what you are validating:
| What | Canonical form |
|---|---|
| DTO / object shape | z.object({ ... }) — static type via z.infer |
| Route parameter | validatedBy(schema, dataBinderValue('query')) in @parameters (from @ndigo/dispatcher) |
| ENTITY field | @column({ schema: z.string().min(2) }) — the ORM derives the full entity schema from metadata |
| A reusable value type | z.string().refine(...) or z.number().min(0) — a schema is the type |
// ✅ Correct: Entity validation is derived from @column metadata@entity('users')class User { @generated() @primaryColumn() id!: number;
@column({ type: 'varchar', schema: z.string().min(2) }) name!: string;}// schemaForEntity(User) / validateEntity(User, inst) from @ndigo/orm
// ✅ Correct: Route param validation via validatedBy@routeMapping('/create')@parameters({ user: validatedBy(UserSchema, dataBinderValue('query')) })create(user: z.infer<typeof UserSchema>) { /* ... */ }Rationale: One schema produces runtime validation and the static type (z.infer), eliminating the as any casts that a decorator-based constraint system and third-party model wrappers required. The ORM, dispatcher, and domain component all consume the same Zod primitive, so there is no competing idiom to choose between.
3.16. Loop Iteration
Section titled “3.16. Loop Iteration”Pattern: Index-based for loops over a list that does not change during the loop cache the length in a constant.
// ✅ Correct: cache the length; the iterated list is not mutated by the bodyconst length = arr.length;for (let index = 0; index < length; index += 1) { // …}
// ✅ Correct: counted-down idioms read the length exactly oncefor (let i = arr.length - 1; i >= 0; i -= 1) { … }let i = arr.length;while (i--) { … }
// ✅ Correct: for-of caches the collection implicitly — prefer it when the// body only needs the element, not an indexfor (const item of arr) { … }// ❌ Wrong: re-reads `arr.length` on every iterationfor (let index = 0; index < arr.length; index += 1) { … }When the live length check is required: if the loop body mutates the
iterated collection (splices/removes/pushes into it) or reassigns it,
the condition must keep re-reading .length — caching would change the
iteration semantics. Default to a snapshot (e.g. items.slice()) when the
body performs listener-style removal, so the cacheable pattern applies.
Rationale: Modern engines hoist plain-array .length loads in simple
loops, so the win is consistency and the pathological cases (accessor-backed
collections like Storage.length, live NodeLists, or bodies that hide
mutation) rather than raw array throughput. One convention across the
codebase avoids per-reviewer debates and makes the rare live-length loop
stand out as intentional.
3.17. Decorator Categories & Naming
Section titled “3.17. Decorator Categories & Naming”Pattern: Every decorator belongs to one category; the category fixes the name’s grammatical form. Pick the category first — the form follows.
| Category | What the name describes | Form | Examples |
|---|---|---|---|
| Stereotypes | What the unit is | noun | @service, @entity, @configuration, @jobHandler, @scope, @entry, @sharedDependency |
| Participatory behaviors | What it participates in | past participle / adjective | @scheduled, @authenticated, @authorized, @transactional, @cached, @traced, @counted |
| Conditions | When it is active | @when* |
@whenFeature, @whenProperty, @whenProfile, @whenNode, @whenBrowser, @whenMissingEntry |
| Registrations | What it registers | <qualifier>Mapping |
@routeMapping, @socketMapping, @stompMapping, @getMapping … |
| Lifecycle hooks | When in a lifecycle it runs | @pre* / @post* + event |
@prePersist, @preUpdate, @postLoad, @postRemove |
| Member descriptors | What a member denotes | established term of art (noun or adjective) | @column, @primaryColumn, @generatedColumn, @property, @defaultValue, @nestedConfig, @signal, @computed, @effect |
| Policies | How behavior is governed | noun = the policy name | @retry, @circuitBreaker, @rateLimit |
| Annotations | What a declaration binds or exposes | noun | @parameters, @apiDocs |
Admission procedure:
- Determine the category from the annotated element’s role — not from the mechanism. “When” phrasing → condition; “registers a …” → registration; a noun for what something is → stereotype; a verb root +
-edfor what it participates in → behavior. - Take the category’s form. Do not invent a new morphology for one decorator.
- Straddling categories: prefer the category matching the annotated element’s role, and record the deviation (with rationale) in the recorded exceptions below — do not bend the category.
Recorded exceptions (do not “fix” without a recorded decision): the signals family keeps its ecosystem vocabulary (@signal, @computed, @effect, @reactive); policies stay policy nouns (retried would misdescribe on-failure semantics); @whenProfile is the single profile gate — there is no @profile.
Rationale: A single global convention could not describe the repository’s decorator surface — conditions and registrations already ran their own forms deliberately. Role-keyed categories give every existing decorator a documented home, give new decorators a decision procedure (“pick the category; the form follows”), and make deviations explicit records instead of accidental drift.
3.18. Mixin Naming
Section titled “3.18. Mixin Naming”Pattern: As* for class mixins, as* for function mixins
Class Mixins (PascalCase with As prefix):
// ✅ Correct: Class mixinsconst EventEmitterMixin = AsEventEmitter(Base);
class MyService extends AsEventEmitter(BaseService) { constructor() { super(); this.emit('initialized'); // From mixin }}
// Pattern: AsEventEmitter(Component) produces new classFunction Mixins (camelCase with as prefix):
// ✅ Correct: Function mixinsconst component = {};asEventEmitter(component); // Adds event emitter methods to instance
component.on('event', handler);component.emit('event', data);
// Pattern: asEventEmitter(target) adds methods to existing objectRationale: Capitalized As* indicates class-level composition (returns new class), lowercase as* indicates instance-level augmentation (modifies existing object).
3.19. Configurable Suffix
Section titled “3.19. Configurable Suffix”Pattern: *Configurable for interfaces/classes with setters
Interfaces or classes that provide setters for normally readonly properties.
// ✅ Correct: Configurable suffixinterface MediaTypeConfigurable extends MediaType { setType(type: string): void; setSubtype(subtype: string): void; setParameters(params: MediaTypeParameters): void;}
interface ViewResolverConfigurable extends ViewResolver { setPrefix(prefix: string): void; setSuffix(suffix: string): void;}Rationale: Clear signal that this variant allows mutation of normally immutable properties.
3.20. Legacy Code Markers
Section titled “3.20. Legacy Code Markers”Pattern: *.legacy.js or Old* prefix
Files or classes marked as deprecated/legacy code.
// ⚠️ Legacy marker in filenameold - component.legacy.js;
// ⚠️ Legacy marker in class nameclass OldComponent { // Deprecated implementation}Rationale: Clearly identifies code scheduled for removal or refactoring. New code should not follow this pattern.
4. Module Organization
Section titled “4. Module Organization”4.1. Directory Structure
Section titled “4.1. Directory Structure”packages/└── package-name/ ├── src/ │ ├── index.ts # Public API barrel export │ ├── _util.ts # Internal utilities │ ├── module-name.ts # Public modules │ └── category/ │ ├── index.ts # Category barrel export │ ├── _helper.ts # Category-internal utilities │ └── specific-module.ts ├── test/ │ ├── _expect.ts # Test framework helpers │ ├── test-cases.ts # Test data │ └── module-name.spec.ts # Test specs ├── lib/ # Compiled output (generated) ├── package.json ├── tsconfig.json └── tsconfig.build.jsonApplications (apps/NN-ndigo-<slug>) use a role-based variant of this layout
(controllers/, services/, entities/, views/) and are not published — see
Guide 91 — Application Conventions.
4.2. Barrel Exports (index.ts)
Section titled “4.2. Barrel Exports (index.ts)”Public API Definition:
// Only export public API - never export underscore files
export { MediaType } from './media-type';export { DataBinder } from './data-binder';export type { Comparable, Comparator } from './interfaces';
// ❌ NEVER do this:// export { helper } from './_util'; // Internal files never exportedCategory Barrel:
// Re-export all public modules in this category
export { toCamel } from './to-camel';export { toPascal } from './to-pascal';export { toKebab } from './to-kebab';export { slugify } from './slugify';
// Internal utilities stay private// (no export for _helpers.ts)4.3. Internal vs Public Modules [AUTOMATED: oxlint]
Section titled “4.3. Internal vs Public Modules [AUTOMATED: oxlint]”Automated: internal/no-internal-file-exports (error) — underscore-prefixed modules must not be
re-exported from public entry points; internal/no-internal-file-imports (error) — they must not be
imported across package boundaries.
Public Modules:
- Exported via
src/index.ts - Stable API subject to semantic versioning
- Documented with TSDoc
- Safe for cross-package use
Internal Modules (_prefix):
- NOT exported in
index.ts - Implementation details
- Can change without notice
- Package-private only
4.4. Utility Function Organization
Section titled “4.4. Utility Function Organization”Organizing helper functions and utilities follows a clear hierarchy based on usage patterns found across the codebase.
Decision Tree
Section titled “Decision Tree”When to inline vs extract utilities:
Single file usage → Inline (regexes, functional constants)3+ files (same category) → Extract to dedicated file4+ files (cross-category) → Add to _util.tsMultiple packages → Promote to @ndigo/utilKey Thresholds:
- Inline: Used by only 1 file
- Extract: Used by 3+ files in same category (e.g., all string utilities)
- Package-wide: Used by 4+ files across categories, or once
_util.tsexists - Global: Used by multiple packages
Inline Utilities
Section titled “Inline Utilities”Keep utilities inline when they’re used only within the file they’re defined in.
Example from packages/ndigo-util/src/string/to-camel.ts:
// ✅ Correct: Inline helpers for single-file useconst dashes = /[\-_]/g;const lowerCaseLetters = /\s[a-z]/g;const spaces = /\s+/g;const upperCaseLetters = /^[A-Z]/g;
// Functional constants (partially-applied functions)const removeSpaces = replace(spaces, '');const replaceDashesWithSpaces = replace(dashes, ' ');
const toCamel: (value: string) => string = pipe( replaceDiacritics, removeNonWord, replaceDashesWithSpaces, (val: string): string => val.replace(lowerCaseLetters, toUpper), removeSpaces, (val: string): string => val.replace(upperCaseLetters, toLower));
export default toCamel;When to use inline utilities:
- Regexes specific to one function
- Partially-applied functions used as pipeline steps
- File-specific configuration constants
- Helper functions with narrow, context-specific use
Extracted to Dedicated File
Section titled “Extracted to Dedicated File”Extract to a separate file when a utility is used by 3+ files within the same category.
Example: packages/ndigo-util/src/string/remove-non-word.ts
This 21-line utility is imported by to-camel.ts, to-pascal.ts, slugify.ts, and other string utilities:
// ✅ Correct: Extracted because used by 3+ string utilities
/** * Remove non-word characters. * @param {string} value The string to be transformed. * @returns {string} The `value` with the non-word characters removed. */const removeNonWord: (value: string) => string = (val: string) => val.replace(/[^\x20\x2D0-9A-Z\x5Fa-z\xC0-\xD6\xD8-\xF6\xF8-\xFF]/g, '');
export default removeNonWord;Guidelines for extracted utilities:
- Single export per file (focused utility modules)
- Import using relative paths from same category
- Typically 10-50 lines
- Well-documented with TSDoc
Example usage:
// In packages/ndigo-util/src/string/to-camel.tsimport removeNonWord from './remove-non-word.js';
// In packages/ndigo-util/src/string/slugify.tsimport removeNonWord from './remove-non-word.js';Package-Wide _util.ts
Section titled “Package-Wide _util.ts”Create a _util.ts when utilities are used by 4+ files across different categories, or once _util.ts exists, add new cross-file utilities there directly.
Example: packages/ndigo-dispatcher/src/_shared.ts
// ✅ Correct: Re-exports + package-specific helpers// Re-export commonly used utilitiesexport { like, isFunction } from '@ndigo/util';export { isString } from '@ndigo/util/string';
// Package-specific helpers used across multiple dispatcher modulesexport const isNonEmptyString = (value: unknown) => isString(value) && value.length > 0;export const notLike = (value: unknown, pattern: unknown) => !like(value, pattern);Examples of existing internal files:
@ndigo/context/src/_event-emitter-helpers.ts@ndigo/dispatcher/src/_shared.ts@ndigo/dispatcher/src/_match.ts@ndigo/platform-node/src/_resolver-util.ts
Guidelines:
- Combines re-exports with package-specific utilities
- Internal only (never exported in
index.ts) - Used by multiple modules within the package
- Avoids duplicating common utility imports
Important: Once a package has _util.ts, add new cross-file utilities there directly instead of waiting for the 4-file threshold.
Important: Internal Files Must Contain Local Utilities
Section titled “Important: Internal Files Must Contain Local Utilities”Internal files (_util.ts, _parser.ts, etc.) should only be created when they contain actual utility functions, not just pass-through re-exports from other packages.
Decision Rule
Section titled “Decision Rule”If you delete all the re-export lines and the file becomes empty, the file shouldn’t exist.
Correct Pattern: Contains Local Utilities
Section titled “Correct Pattern: Contains Local Utilities”/** * Internal utilities for @ndigo/dispatcher package. * * @internal - Do not export in index.ts or import across package boundaries */
// Re-exports (supporting infrastructure)import { like, isFunction } from '@ndigo/util';import { isString } from '@ndigo/util/string';
export { like, isString };
// LOCAL UTILITIES (this is what justifies the file's existence!)export const isNonEmptyString = (value: unknown) => isString(value) && value.length > 0; // ✅ New functionexport const notLike = (value: unknown, pattern: unknown) => !like(value, pattern); // ✅ New functionWhy this is correct: The file creates actual utility functions (isNonEmptyString, notLike). The re-exports are convenience infrastructure to support these utilities and consuming modules.
Wrong Pattern: Only Pass-Through Re-Exports
Section titled “Wrong Pattern: Only Pass-Through Re-Exports”/** * Internal utilities for @ndigo/http package. * * @internal - Do not export in index.ts or import across package boundaries */
// Type utilitiesexport { is, like, isString } from '@ndigo/util';
// String utilitiesexport { slugify, toSentence } from '@ndigo/util/string';
// Package-specific utilities can be added below// (But they never were! This file should NOT exist.)Why this is wrong: No actual utility functions are created. This is just a convenience re-export layer with no value. Consuming files should import directly from @ndigo/util instead.
When to Create Internal Files
Section titled “When to Create Internal Files”✅ Create when:
- You have local helper functions unique to the package
- You need composed/partial functions (e.g.,
join(''),lt(10)) - You have package-specific adaptations of general utilities
- Multiple files (4+) would share these utilities
❌ Don’t create when:
- Just to centralize imports for convenience
- Only for pass-through re-exports without local utilities
- You want to save a few import lines
Naming Internal Files
Section titled “Naming Internal Files”Internal files don’t always need to be named _util.ts. Choose a name that reflects the purpose and content:
Generic Mixed Utilities:
_util.ts- Re-exports from@ndigo/util+ mixed-purpose helpers
Domain-Specific Collections:
_parser.ts- Collection of internal parser primitives_validators.ts- Collection of internal validation helpers_formatters.ts- Collection of internal formatting functions_http-common.ts- HTTP-specific shared utilities (Node.js convention)
Specific Implementations:
_natural-sort.ts- Natural sorting algorithm_media-type-parser.ts- Specific parsing logic
Test Helpers:
test/_expect.ts- Custom test assertionstest/_fixtures.ts- Shared test datatest/_worker.ts- Test worker utilities
Example: Node.js uses descriptive names:
lib/_http_agent.js✅lib/_http_client.js✅lib/_http_common.js✅- NOT
lib/_util.jsfor everything ❌
When to Use Which Name:
Single algorithm → Descriptive: _natural-sort.ts3+ related utilities → Domain-specific: _validators.tsMixed-purpose helpers → Generic: _util.tsTest utilities → test/_<purpose>.tsExample Progression
Section titled “Example Progression”Stage 1: Inline (single file uses)
// In packages/ndigo-http/src/request.tsconst parseAcceptHeader = (header: string) => header.split(',').map(trim);Stage 2: Extract to dedicated file (3+ files use)
export const parseAcceptHeader = (header: string) => header.split(',').map(trim);export const parseContentType = (header: string) => { /* ... */};export const parseCacheControl = (header: string) => { /* ... */};Stage 3: Add to _util.ts (becomes cross-cutting)
import { slugify, toSentence } from '@ndigo/util/string';
export { slugify, toSentence }; // Re-exports
// Cross-cutting utilities used by multiple domainsexport const parseAcceptHeader = (header: string) => header.split(',').map((part) => part.trim());Stage 4: Promote to @ndigo/util (multiple packages use)
export const parseAcceptHeader = (header: string) => header.split(',').map(trim);Future Re-evaluation
Section titled “Future Re-evaluation”Note: The hybrid naming approach (generic _util.ts + specific domain files) will be reviewed at a future date to determine if it should be kept, standardized, or evolved to directory-based organization.
Triggers for review:
-
5 internal files per package (indicates need for directory structure)
-
10% internal file ratio (too many internal files)
- Significant organizational challenges emerge
Promoted to @ndigo/util
Section titled “Promoted to @ndigo/util”Promote utilities to @ndigo/util when they’re used by multiple packages or are truly universal.
When to promote:
- Function is used by 2+ packages
- Logic is universally applicable (not domain-specific)
- Fits into existing
@ndigo/utilcategories (/string,/list,/function, etc.)
Process:
- Move to appropriate category in
@ndigo/util - Add comprehensive TSDoc documentation
- Add thorough test coverage
- Export in
@ndigo/util/src/index.ts - Update consuming packages to import from
@ndigo/util
Higher-Order Functions
Section titled “Higher-Order Functions”Use currying when:
// ✅ Partial application creates useful variantsconst sample = (n: number) => (list: ArrayLike<T>) => ...;const sampleTwo = sample(2); // Reusable!const sampleFive = sample(5);
// ✅ Pipeline compositionconst slugify = (options: SlugOptions) => (value: string) => ...;const defaultSlug = slugify({ lowercase: true, replacement: '-' });const urlSlug = slugify({ lowercase: true, replacement: '_' });Use regular functions when:
// ✅ All parameters always provided togetherfunction toCamel(value: string): string { ... }function isValidDate(value: temporal, format: string): boolean { ... }
// No benefit from partial applicationfunction parseMediaType(value: string): MediaType { ... }Rationale: Currying adds complexity. Only use it when partial application or composition provides real value.
Constants Organization
Section titled “Constants Organization”Inline constants (1-5 constants, single file):
// ✅ Correct: File-specific constantsconst MAX_RETRIES = 3;const WILDCARD = '*';const EMAIL_REGEX = /^[\w.-]+@[\w.-]+\.\w+$/;const DEFAULT_DATE_FORMAT = 'MM/DD/YYYY';Dedicated constant files (10+ related constants):
// ✅ Correct: Large cohesive collections// packages/ndigo-http/src/http-headers.ts (725 lines, 124 constants)export const HTTPHeader = Object.freeze({ Accept: 'Accept', AcceptEncoding: 'Accept-Encoding', AcceptLanguage: 'Accept-Language', Authorization: 'Authorization', CacheControl: 'Cache-Control', ContentType: 'Content-Type', // ... 118 more related constants});Guidelines:
- Inline: 1-5 constants specific to one module
- Extract: 10+ constants forming a cohesive collection
- Large files OK: Constant collections can exceed 500 lines if single responsibility
Real examples:
http-headers.ts: 725 lines, 124 HTTPHeader constantsresponse-statuses.ts: 348 lines, HTTP status code constantsmedia-type.ts: 433 lines, media type constants
Anti-Patterns
Section titled “Anti-Patterns”❌ Premature Extraction:
// ❌ Wrong: Separate file for utility used by only 1 fileconst capitalizeFirst = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);export default capitalizeFirst;
// ✅ Correct: Keep inline in the only file that uses itconst capitalizeFirst = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);const result = capitalizeFirst(input);❌ Missing Extraction (Duplication):
// ❌ Wrong: Same regex in 4 different filesconst spaces = /\s+/g;
// to-pascal.tsconst spaces = /\s+/g;
// to-kebab.tsconst spaces = /\s+/g;
// slugify.tsconst spaces = /\s+/g;
// ✅ Correct: Extract to _util.ts or dedicated file// _util.tsexport const SPACES_REGEX = /\s+/g;
// to-camel.tsimport { SPACES_REGEX } from './_util';❌ Wrong Extraction Level:
// ❌ Wrong: Package-specific utility in @ndigo/util// This is only used by @ndigo/dispatcher modulesexport const resolveMatch = ...; // Should be in @ndigo/dispatcher/src/_match.ts
// ❌ Wrong: Cross-package utility in _util.ts// This is used by @ndigo/core, @ndigo/context, @ndigo/dispatcherexport const deepClone = ...; // Should be promoted to @ndigo/util❌ Circular Dependencies:
// ❌ Wrong: _util.ts imports from module that imports _util.tsimport { helper } from './module-a';
// module-a.tsimport { utility } from './_util'; // CIRCULAR!
// ✅ Correct: Extract shared code to third file or inline in oneSee also: Dependency Management Strategy for guidance on when to promote utilities to @ndigo/util.
4.5. File Size and Module Breakdown
Section titled “4.5. File Size and Module Breakdown”Maintaining appropriate file sizes improves code readability, testability, and maintainability.
Target File Sizes
Section titled “Target File Sizes”Codebase analysis (250 TypeScript files):
| Size Range | Count | Percentage | Assessment |
|---|---|---|---|
| ≤50 lines | 109 files | 43% | Small, focused modules |
| 51-150 lines | 93 files | 37% | Ideal range |
| 151-300 lines | 33 files | 13% | Large but acceptable |
| >300 lines | 15 files | 6% | Requires review |
Statistics:
- Median: 61 lines
- Average: 106 lines
- Target: 20-150 lines per file
Size guidelines:
// ✅ Ideal: Single utility (20-50 lines)// packages/ndigo-util/src/string/remove-non-word.ts (21 lines)
// ✅ Good: Utility with helpers (50-150 lines)// packages/ndigo-util/src/string/to-camel.ts (46 lines)
// ⚠️ Review: Complex module (150-300 lines)// May be acceptable if single cohesive responsibility
// ❌ Likely needs refactoring: Multiple responsibilities (>300 lines)// Probably violates Single Responsibility PrincipleWhen to Break Up Files
Section titled “When to Break Up Files”Break up a file when it exhibits these signals:
1. Multiple Unrelated Exports
// ❌ Wrong: 10 unrelated exportsexport class UserService { ... }export class EmailValidator { ... }export function formatDate(date: Date): string { ... }export const HTTP_CODES = { ... };export function parseJSON(json: string): any { ... }// These belong in separate files!2. Low Cohesion
// ❌ Wrong: Functions don't work togetherexport function validateUser(user: User): boolean { ... }export function fetchWeatherData(city: string): Weather { ... }export function calculateTaxes(income: number): number { ... }// No relationship between these functions3. Frequent Merge Conflicts
Multiple developers editing different sections indicates separate concerns.
4. Hard to Test
Complex setup required for every test suggests too many dependencies.
5. Size + Complexity
// ❌ Wrong: >200 lines doing multiple things// e.g. a 965-line module mixing string validation, number validation,// date validation, type checking, and helpers// Should split by responsibilityHow to Break Up Large Files
Section titled “How to Break Up Large Files”Pattern 1: Extract Constants
// Before: user-service.ts (400 lines)export class UserService { private readonly USER_ROLES = { ADMIN: 'admin', USER: 'user', ... }; private readonly USER_STATUSES = { ACTIVE: 'active', ... }; private readonly EMAIL_REGEX = /^[\w.-]+@/; // ... 50 more constants // ... service methods}
// After: user-constants.ts (60 lines)export const USER_ROLES = { ADMIN: 'admin', USER: 'user', ... };export const USER_STATUSES = { ACTIVE: 'active', ... };export const EMAIL_REGEX = /^[\w.-]+@/;
// After: user-service.ts (340 lines)import { USER_ROLES, USER_STATUSES, EMAIL_REGEX } from './user-constants';
export class UserService { // ... service methods using imported constants}Pattern 2: Extract Helpers to _util.ts
// Before: route-handler.ts (350 lines)class RouteHandler { // ... main logic
private parseParams(route: string): Record<string, string> { ... } private normalizeRoute(route: string): string { ... } private validateRoute(route: string): boolean { ... } // ... 5 more helper methods}
// After: _util.ts (80 lines)export function parseRouteParams(route: string): Record<string, string> { ... }export function normalizeRoute(route: string): string { ... }export function validateRoute(route: string): boolean { ... }
// After: route-handler.ts (270 lines)import { parseRouteParams, normalizeRoute, validateRoute } from './_util';
class RouteHandler { // ... main logic using imported helpers}Pattern 3: Split by Functionality
// Before: event-emitter.ts (771 lines)// - Event registration (on, off, once)// - Event triggering (emit, emitAsync)// - Error handling// - Async event support
// After: Split into focused modules// event-emitter.ts (300 lines) - Core registration + sync triggering// event-errors.ts (150 lines) - Error handling utilities// async-events.ts (200 lines) - Async event support// _event-util.ts (121 lines) - Shared internal helpersAcceptable Large Files
Section titled “Acceptable Large Files”Some files are legitimately large and should not be split:
✅ Constant Collections:
// packages/ndigo-http/src/http-headers.ts (725 lines, 124 constants)// Single cohesive collection of related HTTP header constantsexport const HTTPHeader = Object.freeze({ Accept: 'Accept', AcceptEncoding: 'Accept-Encoding', // ... 122 more headers});Real examples:
http-headers.ts: 725 lines (124 HTTPHeader constants)response-statuses.ts: 348 lines (HTTP status codes)media-type.ts: 433 lines (media type constants)
✅ Complex Classes with Single Responsibility:
// packages/ndigo-factory/src/factory.ts (562 lines)// Single cohesive responsibility: Dependency injection factory// High internal cohesion, all methods serve DI container
// packages/ndigo-context/src/context.ts (363 lines)// Single responsibility: Application context management✅ Framework Integration:
// packages/ndigo-dispatcher/src/route-handler-mapping.ts (884 lines)// Complex route mapping logic with high internal cohesion// Difficult to split without artificial boundariesCriteria for acceptable large files:
- Single cohesive responsibility (high cohesion)
- All code serves the same purpose
- Natural unit that’s hard to split
- Splitting would create artificial boundaries
Functions Per File Guidelines
Section titled “Functions Per File Guidelines”Utility Modules (Single Export):
// ✅ Ideal: 1 main export, 0-6 inline helpers// packages/ndigo-util/src/string/to-camel.ts (46 lines)const dashes = /[\-_]/g; // Helper 1const spaces = /\s+/g; // Helper 2const removeSpaces = replace(spaces, ''); // Helper 3// ... 3 more inline helpers
export default toCamel; // Single exportService Modules:
// ✅ Good: 3-7 related public methodsclass MediaTypeService { parse(value: string): MediaType { ... } format(type: MediaType): string { ... } compare(a: MediaType, b: MediaType): number { ... } isValid(value: string): boolean { ... } // Private helpers don't count toward this limit}Constant Collections:
// ✅ Acceptable: 20-100+ exports if cohesiveexport const HTTPHeader = { ... }; // Object with 124 properties
// or
export const ACCEPT = 'Accept';export const ACCEPT_ENCODING = 'Accept-Encoding';// ... 100+ more related constantsRule of Thumb:
- 1-2 exports: Utility module (ideal)
- 3-7 exports: Service/class module (good)
- 8-15 exports: Consider splitting (review)
- 20+ exports: Acceptable only for constant collections
Refactoring Strategy
Section titled “Refactoring Strategy”Step-by-step approach for breaking up large files:
1. Identify Responsibility Boundaries
# Analyze exportsgrep "^export" large-file.ts
# Look for natural groupings# - Related functions that work together# - Distinct feature areas# - Different abstraction levels2. Extract Low-Hanging Fruit First
Start with easy, low-risk extractions:
// First: Extract constants// constants.ts (new file)export const CONFIG = { ... };
// Second: Extract utilities// _util.ts (new or existing)export function helper() { ... }
// Third: Extract test helpers// test/_helpers.ts (new file)export function createMockUser() { ... }3. Split Core Functionality
After easy extractions, split remaining code:
// Split by feature areauser-service.ts (300 lines)user-validation.ts (150 lines)user-formatting.ts (100 lines)
// Split by abstraction levelhigh-level-api.ts (200 lines)internal-implementation.ts (250 lines)
// Split by lifecycle phaseinitialization.ts (150 lines)runtime-operations.ts (300 lines)cleanup.ts (100 lines)4. Verify No Circular Dependencies
# Build will fail if circular dependencies existpnpm build
# Check specific packagenx run @ndigo/core:build:lib5. Update Tests
// Adjust imports in test filesimport { UserService } from '../src/user-service';import { USER_ROLES } from '../src/user-constants';
// Split test files if needed to match source structureuser - service.spec.ts;user - validation.spec.ts;6. Verify Test Coverage Maintained
# Ensure refactoring didn't reduce coveragepnpm test
# Check coverage reportnx run @ndigo/core:test --coverage7. Document Changes
git commit -m "refactor(validate): split large constraints file
- Extract string constraints to string-constraints.ts (200 lines)- Extract number constraints to number-constraints.ts (150 lines)- Extract date constraints to date-constraints.ts (180 lines)- Move shared helpers to _util.ts (80 lines)- Update tests and maintain 100% coverage
Ref: STYLE_GUIDE.md Section 4.5"Best Practices:
- Make incremental changes (one extraction at a time)
- Run tests after each extraction
- Commit working changes before next extraction
- Document rationale in commit messages
5. Documentation Blocks
Section titled “5. Documentation Blocks”Ndigo source documentation follows the TSDoc standard as parsed by
@microsoft/tsdoc (the same parser wired into oxlint as
internal/tsdoc-syntax). Comments are written so that pnpm doc:api
(TypeDoc) and pnpm lint (oxlint) agree on what is valid. pnpm lint runs
--deny-warnings, so a TSDoc syntax diagnostic is a hard error.
Every source file begins with a single canonical TSDoc block at the very top
of the file, before any imports. The block carries a one-line module summary;
no tags. The summary is what the module-summary TypeDoc plugin lifts onto the
generated module page (packages/<name>/api/<file>/README.md). The same
plugin lifts the package README’s blockquote tagline onto
packages/<name>/api/README.md; @packageDocumentation is not used because
TypeDoc ignores it under entryPointStrategy: expand. This is the
standard form — @ndigo/generators emits it for new packages/components and
the header generator adds/replaces it on existing files:
/** * <One-line summary ending with a period>. */Rules:
- ✅ One block per file, first thing in the file (before imports)
- ✅
<summary>is a single sentence ending in a period; required forsrcmodules, optional for test files - ✅ No tags in the file header — no
@since,@author,@module,@category,@file, or duplicate summary lines. Ndigo follows the TSDoc standard, which defines no version/author tags;@since/@authorare deliberately not used. - ❌ No
@moduletag: TypeDoc derives each module’s name from its source path, and a package-wide tag collapses every file of the package into one module (see Guide 90 — Package Documentation) - ❌ Headings (
#) inside doc blocks - ❌
TODO:summary placeholders — describe what the file does - ✅ Generate with:
pnpm exec nx generate @ndigo/generators:header --file <path> --summary "…"
The file-level block documents the whole module; class/method members carry
their own focused TSDoc. internal/requires-doc-header (oxlint, error level —
.oxlintrc.json) enforces the canonical block on source files. Module-summary
presence is gated per package by scripts/doc-coverage.mjs.
Public symbol comments (coverage gate): every top-level exported
function, class, interface, or type alias needs a doc comment — the docs
pipeline (pnpm doc:api → coverage gate) fails CI without one. A one-line
summary suffices for self-evident type aliases and predicate helpers:
/** Whether the lifecycle has finished initializing. */export function isInitialized(value: unknown): boolean;Depth gate (zero tolerance): pnpm doc:coverage also fails CI when any
source module lacks a real summary, any exported class/interface member lacks
a doc comment, any method with parameters omits a parameter’s @param, or any
function with a non-void return omits @returns. Document members too —
variables and enum members are the only exemptions, because their names
self-document. @internal and _-prefixed internal files are exempt. See
Guide 90 — Package Documentation.
Standard tags. Use only TSDoc-standard tags: @remarks, @example,
@param, @typeParam, @returns, @throws, @defaultValue,
@deprecated, @see, @internal, @inheritDoc, {@link}. Do not use
JSDoc-only tags (@template, @default, @inheritdoc, @protected,
@private, @extends, @category, @external, @global, @typedef,
@class, @abstract, @property, @todo); migrate them to the standard
forms (@typeParam, @defaultValue, {@inheritDoc}, or prose). @example
code must be valid TypeScript and is syntax-checked by
scripts/doc-examples.mjs.
{@link} targets follow the file, not the package. The reference is
generated one module per source file, so an unqualified {@link Name} only
resolves inside the file that declares Name. Link across files of the same
package with the declaring module as the qualifier and keep the printed text
short:
// ❌ Fails the docs build (unresolved link) when DrizzleEntityManager lives// in drizzle-entity-manager.ts* {@link DrizzleEntityManager}
// ✅ Same package, another file* {@link drizzle-entity-manager.DrizzleEntityManager | DrizzleEntityManager}Class members qualify with # ({@link cache.CacheOptions#maxSize}). A link
to another package goes in scripts/external-symbol-links.json — never
inline.
6. Complete Example
Section titled “6. Complete Example”Here’s a complete example demonstrating all conventions:
/** * Media type parser and comparator. */
// Imports: Always use single quotesimport * as R from '@ndigo/util';import { AbstractValueComparator } from './_util'; // Internal import OK in same package
// True constants: UPPER_SNAKE_CASEconst DEFAULT_QUALITY = 1.0;const WILDCARD = '*';const QUALITY_PARAM = 'q';
// Functional constants: camelCaseconst sortBySpecificity = R.sortBy(R.prop('specificity'));const compareQuality = R.descend(R.prop('quality'));
// Type definitionstype MediaTypeParams = { [key: string]: string | number;};
type qualityValue = number; // Simple alias: camelCase
// Interfaces: PascalCaseinterface Comparable<T> { compareTo(other: T): number;}
// Classes: PascalCaseclass MediaType implements Comparable<MediaType> { // Private fields: # prefix (modern) #type: string; #subtype: string; #parameters: MediaTypeParams;
// Constructor constructor(type: string, subtype: string, parameters: MediaTypeParams = {}) { this.#type = type; this.#subtype = subtype; this.#parameters = parameters; }
// Getters: camelCase get type(): string { return this.#type; }
get subtype(): string { return this.#subtype; }
// Methods: camelCase compareTo(other: MediaType): number { return this.specificity - other.specificity; }
getParameter(name: string): string | number | undefined { return this.#parameters[name]; }
// Predicate method: is* prefix isWildcard(): boolean { return this.#type === WILDCARD || this.#subtype === WILDCARD; }
// Static factory: camelCase static for(type: string, subtype: string): MediaType { return new MediaType(type, subtype); }}
// Functions: camelCasefunction parseMediaType(value: string): MediaType { const [type, subtype] = value.split('/'); return MediaType.for(type, subtype);}
// Predicate function: is* prefixfunction isValidMediaType(value: string): boolean { return /^[\w-]+\/[\w-]+$/.test(value);}
// Duck typing function: like* prefixfunction likeComparable<T>(value: unknown): value is Comparable<T> { return ( value != null && typeof value === 'object' && 'compareTo' in value && typeof (value as any).compareTo === 'function' );}
// Export: Only public APIexport { MediaType, parseMediaType, isValidMediaType, likeComparable };export type { MediaTypeParams, Comparable };
// Never export internal utilities:// ❌ export { helper } from './_util';7. Maintenance
Section titled “7. Maintenance”7.1. When oxfmt Config Changes
Section titled “7.1. When oxfmt Config Changes”IMPORTANT: When .oxfmtrc.json is modified, update Section 1 of this guide to reflect the new formatting rules.
Process:
-
Make changes to
.oxfmtrc.json -
Update corresponding section in
STYLE_GUIDE.md -
Run
pnpm formatto apply new formatting -
Document the change in commit message:
style: update oxfmt config and style guideChanged [setting] from [old] to [new].Updated STYLE_GUIDE.md Section 1 to document the new formatting rule.
7.2. When Establishing New Patterns
Section titled “7.2. When Establishing New Patterns”If a new naming pattern or convention is established:
- Document it in the appropriate section of this guide
- Provide rationale and examples
- Update
AGENTS.mdif agents need to follow the pattern - Update
README.mdif it affects user-facing conventions - Note the change in commit message
7.3. Style Decisions
Section titled “7.3. Style Decisions”When making style decisions, document the rationale in git commit messages and consider adding to this guide if it establishes a precedent.
8. Self-Documenting Code
Section titled “8. Self-Documenting Code”Code is read more often than it is written. Self-documenting code minimizes the gap between what the code says and what the author intended.
8.1. Names Carry Meaning, Comments Carry Rationale
Section titled “8.1. Names Carry Meaning, Comments Carry Rationale”| Category | What to document | What NOT to document |
|---|---|---|
| Names | Domain concepts, roles (*Handler, *Strategy) |
Trivial getters (// Returns the X) |
| Comments | Why a choice was made, edge cases, tradeoffs | What the code does (the code says that) |
| Assertions | Why the compiler is wrong (one-liner with assertion) | That it’s wrong (the as any already says that) |
| Dead code | @deprecated with migration path, or delete |
Commented-out blocks (“I’ll need this later”) |
8.2. Properties and Methods Signal Cost and Semantics
Section titled “8.2. Properties and Methods Signal Cost and Semantics”As established in Section 3.12:
| Shape | Convention | Signals to the reader |
|---|---|---|
get something() |
Property | O(1), no side effects, intrinsic data |
getSomething() |
get-method | May be expensive, may fail, may return null |
toSomething() |
to-method | Returns a new representation, source unchanged |
8.3. Comments Explain Why, Not What
Section titled “8.3. Comments Explain Why, Not What”// ❌ WHAT — the code already says this// Increment the OTel countercounter.add(1, config.attributes);
// ✅ WHY — explains a non-obvious design decision// The retry creates a new Request object, so the WeakMap key// from the previous attempt is lost. The x-retry-attempt header// carries the counter without per-instance mutable state.const RETRY_ATTEMPT_HEADER = 'x-retry-attempt';8.4. Type Assertions Explain the Compiler Contract
Section titled “8.4. Type Assertions Explain the Compiler Contract”Every as any, as unknown as T, or // @ts-ignore must carry a one-line reason:
// ❌ Bare assertion — reader must reverse-engineer whyconst metadata = (handler as any).constructor?.[Symbol.metadata];
// ✅ With rationale — reader immediately knows the gap// constructor[Symbol.metadata] is a runtime-only TC39 decorator property// not declared on the `Function` type in TS 5.7.const metadata = (handler?.constructor as any)?.[Symbol.metadata];When a module has many similar assertions (e.g., a library whose types end at any), a single module-level comment suffices:
// NOTE: this third-party runtime resolves types dynamically, so the fluent// API returns `any`. A typed wrapper would require wrapping every call —// this is accepted for the integration layer.8.5. // NOTE: Comments Are Technical Debt
Section titled “8.5. // NOTE: Comments Are Technical Debt”A // NOTE: comment usually means “this is not right but I’m shipping it anyway.” It obscures the fact that the code is knowingly incomplete. Prefer:
Instead of // NOTE: |
Use |
|---|---|
| “This is simplified” | @remarks This is a reference implementation. For production use… |
| “Not implemented” | @deprecated Use {@link ProductionJWTAdapter} instead. |
| “In production, do X” | Remove the comment and do X, or mark with @deprecated |
| “This should be X” | Do X, or open a ticket |
8.6. File Size Is Documentation
Section titled “8.6. File Size Is Documentation”A file containing one public type documents the relationship between name and content. A file containing seven classes documents nothing — the reader must reverse-engineer the grouping. Follow the one-public-type-per-file convention:
- ✅
abstract-path-handler-mapping.tscontainsAbstractPathHandlerMapping - ❌
route-handler-mapping.ts(1218 lines) contained 7 public classes, 3 internal helpers, and 2 interfaces
Guideline: Keep files under 300 lines. Files over 300 lines should have a clear single-responsibility justification. Split by public type, not by author convenience.
8.7. Dead Code Carries Its Tombstone
Section titled “8.7. Dead Code Carries Its Tombstone”| State | Treatment |
|---|---|
| Intentionally unused | Delete it. Source control remembers it. |
| Kept for future activation | @deprecated with a @remarks explaining when it will be needed, or move to an _archive/ directory |
| Commented-out | Delete it. |
| Optional path you chose not to take | Document the decision in a decision record or the commit message |
8.8. Reuse the Contract Before Hand-Rolling
Section titled “8.8. Reuse the Contract Before Hand-Rolling”@ndigo/core, @ndigo/factory, and @ndigo/util carry a shared contract
vocabulary. When a new piece of code reaches for an ad-hoc implementation of
one of those patterns, wire the contract instead:
| Hand-rolled idiom | Reuse instead |
|---|---|
Multi-line typeof x.prop === 'function' shape chain |
like({...}) one-liner, or an existing like* guard (likeDestroyable, likeMatchable, likeJobDriver) |
Inline .sort((a, b) => a.x < b.x ? -1 : a.x > b.x ? 1 : 0) |
stringComparator / numberComparator (+ compareBy for key triples, chainComparisons/invertComparison for multi-key and descending) |
.sort((a, b) => a.x - b.x) |
numberComparator.compare (via compareBy when extracting a key) |
Class with equals() / compareTo() / toJSON() / close() / destroy() that never declares its contract |
Declare Equatable / Comparable / Serializable / Closeable / Destroyable on the class or extend the owning contract interface |
Rules of engagement:
- A class that structurally matches a contract names the contract in its
implementsclause — the compiler then enforces future changes. - Closed-resource guards throw
IllegalStateError; domain errors extendFrameworkError/ApplicationError(see Contract-First Patterns). - Exclusions are documented at the exclusion site (e.g.
HTTPServercloses throughSmartLifecycle.stop();SocketSession.close(code, reason)takes per-session parameters).
Last Updated: November 6, 2026
This guide is a living document. When code style evolves, update this guide to reflect current practices.