Function Patterns
Consistent function patterns improve code readability and maintainability.
Module Exports
Prefer Named Exports
Avoid export default except for configuration files (e.g., .configs/lints/oxlint.config.mts) or lazy-loaded components. Use named exports for all classes, functions, and constants.
Why?
- Refactoring: Renaming a symbol automatically updates imports across the monorepo
- Consistency: Enforces consistent naming across all files importing the module
// ✅ GOOD
export class UserController { }
export const createUser = () => { };
export const DEFAULT_OPTIONS = { };
// ❌ BAD
export default class UserController { }
export function createUser() { } // arrow functions only, never `function`The Options Object Pattern
Every function and constructor takes a single object parameter named opts. This is not a preference and it has no argument-count threshold.
Why?
- Extensibility: You can add new properties without breaking existing calls
- Readability: Named keys act as documentation at the call site
// ✅ GOOD
class UserService {
createUser(opts: { name: string; email: string; role?: string }) {
// ...
}
}
// Usage: service.createUser({ name: 'John', email: 'john@example.com' });
// ❌ BAD
class UserService {
createUser(name: string, email: string, role?: string) {
// ...
}
}
// Usage: service.createUser('John', 'john@example.com');One parameter is not an exception
A single positional parameter is the tempting case. Take the object anyway.
// ✅ GOOD
isValidName(opts: { name: string }): boolean;
// ❌ BAD
isValidName(name: string): boolean;One positional parameter reads fine until the second one arrives. By then the method is published, and the cost moves from the one person writing it to every caller. IStorageHelper kept three methods positional on that reasoning. Converting them later touched 72 call sites in this repository.
Mixing both shapes inside one interface is its own defect. A reader cannot tell which shape a method takes without opening it.
Function Naming Conventions
Use consistent prefixes based on function purpose:
| Prefix | Purpose | Examples |
|---|---|---|
generate* | Create column definitions / schemas | generateIdColumnDefs(), generateTzColumnDefs() |
build* | Construct complex objects | buildValueCondition(), buildJsonOrderBy() |
to* | Convert/transform data | toCamel(), toBoolean() |
is* | Boolean validation/check | isApplicationError(), isInt(), isFloat(), isPromiseLike() |
has* | Boolean ownership check | hasBucket(), hasPermission() |
assert* | Throws when the condition fails, returns nothing | assertBucketExists(), assertOwner() |
extract* | Pull out specific parts | extractTimestamp(), extractWorkerId(), extractSequence() |
enrich* | Enhance with additional data | enrichUserAudit(), enrichWithMetadata() |
get* | Retrieve/fetch data | getSchema(), getConnector(), getError() |
resolve* | Determine/compute value | resolveValue(), resolveClass() |
is* versus assert*
They answer the same question and differ in what happens next. is* returns a boolean and leaves the decision to the caller. assert* throws and returns void, so the code after it can rely on the condition without a branch.
Reach for assert* when every caller would throw on false anyway - the branch is then noise, and each caller writing its own error message is how one condition ends up with four different codes.
Examples:
// Generators - create schema definitions
const idCols = generateIdColumnDefs({ id: { dataType: 'string' } });
const tzCols = generateTzColumnDefs();
// Builders - construct complex query objects
const condition = buildValueCondition(column, value);
const orderBy = buildJsonOrderBy(schema, path, direction);
// Converters - transform data types
const camelCase = toCamel('snake_case');
const bool = toBoolean('true');
// Validators - boolean checks
if (isApplicationError(error)) { /* ... */ }
if (isInt(value)) { /* ... */ }
if (isPromiseLike(result)) { /* ... */ }
// Extractors - pull specific data
const timestamp = extractTimestamp(snowflakeId);
const workerId = extractWorkerId(snowflakeId);Scope Naming
Every class extending a base class should set its scope using ClassName.name:
export class PaymentService extends BaseService {
constructor() {
super({ scope: PaymentService.name });
}
}
export class UserController extends BaseRestController {
constructor() {
super({ scope: UserController.name, path: '/users' });
}
}Performance Logging Pattern
Use performance.now() for timing critical operations with method-scoped logging:
async syncData() {
const t = performance.now();
this.logger.for('syncData').info('START | Syncing data...');
// ... operation to measure ...
this.logger.for('syncData').info('DONE | Took: %s (ms)', performance.now() - t);
}With the helper utility:
import { executeWithPerformanceMeasure } from '@venizia/ignis-helpers';
await executeWithPerformanceMeasure({
logger: this.logger,
level: 'info', // default: 'debug'
scope: 'DataSync',
description: 'Sync user records',
task: async () => {
await syncAllUsers();
},
});
// Logs: [DataSync] DONE | Sync user records | Took: 1234.56 (ms)Method-scoped logging pattern:
Any class extending BaseHelper (services, controllers, repositories, helpers) already has this.logger. Acquire one standalone with ApplicationLogger.get(...) and always annotate ILogger - never a concrete provider class.
import { ApplicationLogger, ILogger } from '@venizia/ignis-helpers';
class UserService {
private logger: ILogger = ApplicationLogger.get('UserService');
async createUser(data: TCreateUserRequest) {
// .for() returns a method-scoped child logger
this.logger.for('createUser').info('Creating user: %j', data);
// Output: [UserService-createUser] Creating user: {...}
try {
const { data: user } = await this.userRepository.create({ data });
this.logger.for('createUser').info('User created: %s', user.id);
return user;
} catch (error) {
// %s, not %j - `message` and `stack` are non-enumerable, so %j drops them
this.logger.for('createUser').error('Failed: %s', error);
throw error;
}
}
}Levels are exactly five, each a direct method: debug, info, warn, error, emerg.
See Also
- Naming Conventions - Class and file naming
- Type Safety - Typed function signatures
- Route Definitions - Controller methods