Skip to content

Design Patterns

This document describes recurring design patterns used in the gaslamp codebase. Following these patterns keeps the API consistent and easier to learn across modules.

Constructor Pattern

Standard Signature

All classes that accept a name and configuration options use the following two-argument constructor:

TypeScript
constructor(
  name: string = ClassName.defaultName,
  options: ClassOptions = {}
)
  • name — A string identifier used for logging and debugging. Defaults to ClassName.defaultName.
  • options — An options object that extends LoggerOptions. Always has a default of {}.

Examples

TypeScript
// AfterGlow
const logger = new AfterGlow();
const logger = new AfterGlow("myLogger");

Classes That Follow This Pattern

Class Default Name Options Type
AfterGlow "AfterGlow" (name only)

Options Object Structure

Each class defines its own options type by combining a core options interface with LoggerOptions:

TypeScript
interface FlameWrightCoreOptions {
  strict?: boolean;
}

type FlameWrightOptions = FlameWrightCoreOptions & LoggerOptions;

This allows options like logLevel, logMode, and loggerName to be passed consistently to any class that uses AfterGlow internally.

Rationale

  • Positional name keeps the most common use case (new FlameWright("myValidator")) concise.
  • Optional options object avoids long positional argument lists when configuring logging or behavior.
  • Consistent across classes means users only need to learn the pattern once.
  • defaultName as a static constant makes the default easy to reference and override without magic strings.

JunkPocket Exception

JunkPocket uses only a required name argument and has no options object, because it is a low-level cache utility with no logging dependency:

TypeScript
constructor(name: string)

Entry Point Pattern

Each module exposes a single index.ts as its public API. The entry point is responsible for two things:

  1. Re-exporting all public symbols from sub-modules
  2. Nothing else — no logic, no side effects
TypeScript
// src/afterglow/index.ts
export * from "./afterglow";
export * from "./ash";
export * from "./scroll";

Rules

  • All modules must have an index.ts.
  • Use export * for sub-modules unless a name conflict requires an explicit alias.
  • Do not put globalThis registration or any runtime logic in module index.ts files. That belongs in src/index.ts (see GAS Global Namespace Pattern).

Name Conflict Resolution

When two sub-modules export the same name (e.g. LoggerOptions), use an explicit alias in the module's index.ts:

TypeScript
// Avoid: export * from "./afterglow";  ← conflicts with LoggerOptions from other modules
export type { LoggerOptions as AfterGlowLoggerOptions } from "./afterglow";

GAS Global Namespace Pattern

Google Apps Script does not support ES modules at runtime. To make classes and functions available as global variables in GAS, src/index.ts registers all public symbols onto globalThis.

Location

All globalThis assignments live exclusively in src/index.ts. Individual module index.ts files do not register globals.

Structure

TypeScript
// src/index.ts

// 1. Export all modules for Node.js / TypeScript consumers
export * from "./torch";
// ...

// 2. Import symbols that need to be registered as GAS globals
import { BareFrame, FlameGuards } from "./torch";
// ...

// 3. Define a typed interface for the GAS global namespace
interface GASGlobalThis {
  BareFrame?: typeof BareFrame;
  FlameGuards?: typeof FlameGuards;
  // ...
}

// 4. Register globals at module load time
if (typeof globalThis !== "undefined") {
  const gas = globalThis as unknown as GASGlobalThis;
  gas.BareFrame = BareFrame;
  gas.FlameGuards = FlameGuards;
  // ...
}

GASGlobalThis Interface

Always define GASGlobalThis as a typed interface. Do not use declare const globalThis: any or cast to Record<string, unknown>.

TypeScript
// Good
interface GASGlobalThis {
  DataFrame?: typeof DataFrame;
}
const gas = globalThis as unknown as GASGlobalThis;

// Bad — loses type safety
declare const globalThis: any;
globalThis.DataFrame = DataFrame;

Namespace vs Flat Registration

Some symbols are registered as a namespace object (e.g. JestLite) rather than flat globals, to avoid polluting the GAS global scope:

TypeScript
// Namespace object — accessed as JestLite.describe() in GAS
gas.JestLite = { describe, runTests, TestSuite, Expect, Matcher };

// Flat globals — accessed directly as DataFrame, FlameGuards, etc.
gas.DataFrame = DataFrame;
gas.FlameGuards = FlameGuards;

Use a namespace when the symbols are tightly coupled and unlikely to be used individually.

Adding a New Global

When adding a new class or function that should be available in GAS:

  1. Add the symbol to the GASGlobalThis interface in src/index.ts
  2. Add the assignment in the if (typeof globalThis !== "undefined") block
  3. Do not add globalThis registration anywhere else

Options Pattern

This section describes how to define and place options types (interfaces and type aliases ending in Options) when implementing new classes or functions.

Rule 1: Define options in the same file as the class

Options types must be defined in the same file as the class or function that uses them, immediately before the class definition.

Do not create a separate options.ts or types.ts file for options alone.

TypeScript
// my-feature.ts

interface MyFeatureCoreOptions {
  strict?: boolean;
}

type MyFeatureOptions = MyFeatureCoreOptions & LoggerOptions;

export class MyFeature {
  constructor(name: string, options: MyFeatureOptions = {}) { ... }
}

Rule 2: Use the two-level options type

Each class that accepts configuration uses a two-level structure:

TypeScript
// 1. Core options — module-specific fields only
interface FooCoreOptions {
  strict?: boolean;
}

// 2. Combined options — intersect core with LoggerOptions
type FooOptions = FooCoreOptions & LoggerOptions;
  • *CoreOptions — holds only the module-specific settings.
  • *Options — the public type passed to the constructor; extends core with LoggerOptions.

This keeps the module-specific API explicit while allowing logging to be configured uniformly:

TypeScript
const fw = new FlameWright("validator", { strict: true, logLevel: "warn" });
const cs = new ClockSmith("timer", { logTarget: "console" });

Rule 3: Factory method options do not extend LoggerOptions

Options for factory methods (e.g. fromArrays, fromSheet) are independent of the constructor options and do not extend LoggerOptions. Define them in the same file as the factory class or function.

TypeScript
interface FromArraysOptions {
  header?: boolean;
}

interface FromSheetOptions {
  header?: boolean;
  skipRows?: number;
}

Rule 4: Export options types with export type

All public options types must be re-exported from the module's index.ts using export type { ... } — not export { ... }:

TypeScript
// Good
export type { MyFeatureOptions, MyFeatureCoreOptions } from "./my-feature";

// Avoid — loses the type-only signal and may affect bundle output
export { MyFeatureOptions } from "./my-feature";

Rule 5: Add TypeDoc comments to all public options types

All exported options interfaces require a TypeDoc comment block with:

  • One-line summary
  • @group — high-level module set (e.g. Torch, AfterGlow, Gears)
  • @category — submodule or functional area (e.g. DataFrame, Logger)
  • @since — the version when the interface was introduced
  • Inline /** ... */ comment for each field
TypeScript
/**
 * Configuration options for MyFeature instances.
 *
 * @category Gears
 * @since 0.X.0
 */
export interface MyFeatureCoreOptions {
  /** Enable strict validation mode. Default: false. */
  strict?: boolean;
}

Exception: modules without AfterGlow dependency

Modules that are designed to be self-contained (e.g. jestlite/) must not extend LoggerOptions. Instead, accept any object with a .log() method via structural typing:

TypeScript
export interface MyModuleLoggerOptions {
  logger?: { log: (...args: unknown[]) => void };
}

This avoids a hard dependency on AfterGlow and keeps the module independently usable.

GAS-First Argument Design Pattern

This section describes how to design method signatures for GAS compatibility.

Rule 1: Use only JavaScript built-in types and GAS types as arguments

Method arguments must use JavaScript built-in types (string, number, boolean, Array, Map, etc.) or GAS types (GoogleAppsScript.Spreadsheet.Sheet, etc.) only.

Do not use complex TypeScript-only constructs (generics, conditional types, mapped types) as argument types in public methods.

TypeScript
// Good — string is a JS built-in
public filter(column: string, value: string): DataFrame { ... }

// Good — GAS type
public fromSheet(sheet: GoogleAppsScript.Spreadsheet.Sheet): DataFrame { ... }

// Avoid — complex TypeScript-only type
public transform<T extends Record<string, unknown>>(mapper: T): DataFrame { ... }

Rule 2: No TypeScript overloads — use per-type methods with a dispatcher

TypeScript function overloads require the TypeScript compiler to resolve the correct signature. This does not exist at GAS runtime. Instead, create one method per input type and a single dispatcher that routes to them.

TypeScript
// Avoid — TypeScript overload, not usable in GAS
public sort(column: string): DataFrame;
public sort(columns: string[]): DataFrame;
public sort(column: string | string[]): DataFrame { ... }

// Good — dispatcher + per-type methods
public sort(column: string | string[]): DataFrame {
  return Array.isArray(column)
    ? this.sortByColumns(column)
    : this.sortByColumn(column);
}

private sortByColumn(column: string): DataFrame { ... }
private sortByColumns(columns: string[]): DataFrame { ... }

The dispatcher (sort) uses a simple Array.isArray or typeof check, which is plain JavaScript and works in GAS. The per-type methods hold the actual logic.

This pattern is already used in DataFrameOperations:

TypeScript
// combine.ts — join() dispatches to joinInner / joinLeft / joinRight / joinOuter
public join(other: DataFrame, options: JoinOptions = {}): DataFrame {
  switch (how) {
    case "left":  return this.joinLeft(other, joinCol);
    case "right": return this.joinRight(other, joinCol);
    case "outer": return this.joinOuter(other, joinCol);
    default:      return this.joinInner(other, joinCol);
  }
}

Rule 3: Each class owns its own type conversion

When a class wraps a complex internal representation, it must provide a method to convert itself to a plain JavaScript type. Callers should never reach inside the class to extract the raw value.

The canonical example is Expression.toFunction():

TypeScript
// expression.ts
toFunction(options: { strict?: boolean } = {}): (row: Map<string, any>) => boolean | any {
  return (row) => this.evaluate(row, options);
}

// Usage — DataFrame.filter() calls toFunction() internally, not evaluate() directly
const isAdult = new Expression('age').ge(18).toFunction();
const adults = df.filter(isAdult);

Apply this pattern whenever a class holds state that needs to be consumed as a function, array, plain object, or other primitive representation.

Class Conversion method Returns
Expression toFunction() (row: Map<string, any>) => boolean \| any

Add new rows here when introducing similar conversion methods.