Skip to content

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
typescript
// ✅ 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
typescript
// ✅ 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.

typescript
// ✅ 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:

PrefixPurposeExamples
generate*Create column definitions / schemasgenerateIdColumnDefs(), generateTzColumnDefs()
build*Construct complex objectsbuildValueCondition(), buildJsonOrderBy()
to*Convert/transform datatoCamel(), toBoolean()
is*Boolean validation/checkisApplicationError(), isInt(), isFloat(), isPromiseLike()
has*Boolean ownership checkhasBucket(), hasPermission()
assert*Throws when the condition fails, returns nothingassertBucketExists(), assertOwner()
extract*Pull out specific partsextractTimestamp(), extractWorkerId(), extractSequence()
enrich*Enhance with additional dataenrichUserAudit(), enrichWithMetadata()
get*Retrieve/fetch datagetSchema(), getConnector(), getError()
resolve*Determine/compute valueresolveValue(), 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:

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

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

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

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

typescript
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 ​