-
Notifications
You must be signed in to change notification settings - Fork 2
Utils Options
Abstract base class combining options management with event handling.
The Options class provides a powerful foundation for classes that need:
- Options Management: Type-safe configuration handling
- Default Values: Automatic default value application
- Event System: Built-in Events functionality
- Event Registration: Constructor-based event handler setup
- Type Safety: Full TypeScript generic support
deno add @tundralibs/utilsimport { type EventOptionKeys, Options } from '@tundralibs/utils';
type O = { host: string; port: number };
type E = { connect: () => void };
const defaults: Partial<O> = { port: 5432 };
class MyClass extends Options<O, E> {
constructor(config: EventOptionKeys<O, E>) {
super();
this._setOptions(config, defaults);
}
}O and E must be type aliases, not interfaces. Both are
constrained to Record<string, unknown>, and TypeScript refuses an
interface there — Index signature for type 'string' is missing in type 'MyOptions'. It is not a bug in this class: interfaces are open, since
declaration merging lets another file add members later, so TypeScript
will not grant them the implicit index signature that proves the shape
is a closed, string-keyed bag. A type alias with identical members is
closed, so it qualifies.
If the shape comes from generated code or a third-party package and you cannot change it, intersect it:
import { type EventOptionKeys, Options } from '@tundralibs/utils';
interface Generated {
host: string;
port: number;
}
type MyOptions = Generated & Record<string, unknown>;
class Client extends Options<MyOptions> {
constructor(config: EventOptionKeys<MyOptions>) {
super();
this._setOptions(config, {});
}
}-
_setOptions(options, defaults): Apply defaults, then options, with GROUP-AWARE merging: a partial plain-object group (server: { port: 8080 }) merges UNDER the group's defaults instead of replacing them; arrays and class instances replace wholesale; an explicitly-undefinedvalue defers to an existing default (and still reaches_processOptionwhen there is none, so required-option validation works) -
_getOption<K>(key): Read one option (protected — option bags routinely carry credentials; expose values through purpose-built public getters) -
_hasOption(key)(protected): Check if option exists -
_getOptions(): Read a defensive copy of the whole bag (nested plain-object groups are copied too — mutating the result never writes into the store) -
_setOption<K>(key, value): Write ONE option outside the constructor (e.g. from asetHost(host)method) — the value passes through_processOptionexactly like an option supplied to_setOptionsdoes -
_processOption(key, value): Override this to validate or coerce — it is the ONLY path a value can take into the store, so it is the single place to enforce every rule, whether the value arrived via the constructor or a later_setOptioncall. Default: returns the value unchanged. Throw to reject. - All public Events methods (
on,off,once); emission is the protected_emit, for subclasses
_processOptionis the only validation seam. A check written anywhere else — e.g. anifin the constructor after_setOptionsreturns — only guards the values supplied at construction time; it does nothing for a laterthis._setOption('port', badValue)call. See "Validating options" below.
import { type EventOptionKeys, Options } from '@tundralibs/utils';
type DatabaseOptions = {
host: string;
port: number;
ssl?: boolean;
};
type DatabaseEvents = {
connect: () => void;
error: (error: Error) => void;
query: (sql: string) => void;
};
class Database extends Options<DatabaseOptions, DatabaseEvents> {
constructor(config: EventOptionKeys<DatabaseOptions, DatabaseEvents>) {
super();
// Set options with defaults
this._setOptions(config, {
port: 5432,
ssl: false,
});
}
connect() {
const host = this._getOption('host');
const port = this._getOption('port');
const ssl = this._getOption('ssl');
console.log(`connecting to ${host}:${port} (ssl: ${ssl})`);
// Connection logic...
this._emit('connect');
}
query(sql: string) {
this._emit('query', sql);
// Query logic...
}
}
// Usage
const db = new Database({
host: 'localhost',
_onconnect: () => console.log('Connected!'),
_onerror: (err) => console.error('Error:', err),
_onquery: (sql) => console.log('Query:', sql),
});import { type EventOptionKeys, Options } from '@tundralibs/utils';
declare const writeToFile: (message: string) => void;
declare const sendToServer: (message: string) => void;
type LoggerOptions = {
level: string;
output: string;
};
type LoggerEvents = {
log: (message: string) => void;
};
class Logger extends Options<LoggerOptions, LoggerEvents> {
constructor(config: EventOptionKeys<LoggerOptions, LoggerEvents>) {
super();
this._setOptions(config, {
level: 'info',
output: 'console',
});
}
log(message: string) {
this._emit('log', message);
}
}
const logger = new Logger({
level: 'debug',
_onlog: [
(msg) => console.log(msg),
(msg) => writeToFile(msg),
(msg) => sendToServer(msg),
],
});import { type EventOptionKeys, Options } from '@tundralibs/utils';
type ServerOptions = {
port: number;
host: string;
timeout?: number;
};
type ServerEvents = {
start: () => void;
stop: () => void;
request: (req: Request) => void;
error: (error: Error) => void;
};
class HttpServer extends Options<ServerOptions, ServerEvents> {
constructor(config: EventOptionKeys<ServerOptions, ServerEvents>) {
super();
this._setOptions(config, {
port: 3000,
host: '0.0.0.0',
timeout: 30000,
});
}
start() {
const port = this._getOption('port');
const host = this._getOption('host');
console.log(`listening on ${host}:${port}`);
// Start server...
this._emit('start');
}
handleRequest(req: Request) {
this._emit('request', req);
}
}
const server = new HttpServer({
port: 8080,
_onstart: () => console.log('Server started'),
_onrequest: (req) => console.log('Request:', req.url),
_onerror: (err) => console.error('Server error:', err),
});import { type EventOptionKeys, Options } from '@tundralibs/utils';
type PluginOptions = {
name: string;
enabled?: boolean;
};
type PluginEvents = {
load: () => void;
unload: () => void;
execute: (data: unknown) => void;
};
abstract class Plugin extends Options<PluginOptions, PluginEvents> {
constructor(config: EventOptionKeys<PluginOptions, PluginEvents>) {
super();
this._setOptions(config, { enabled: true });
}
load() {
if (this._getOption('enabled')) {
this._emit('load');
this.onLoad();
}
}
abstract onLoad(): void;
abstract execute(data: unknown): void;
}
class MyPlugin extends Plugin {
onLoad() {
console.log(`${this._getOption('name')} loaded`);
}
execute(data: unknown) {
this._emit('execute', data);
// Plugin logic...
}
}- Defaults First: Always provide sensible defaults
- Type Safety: Define option and event interfaces
-
Validation: Validate in
_processOption, not with anifafter_setOptions— that only catches construction-time values, not a later_setOptioncall (see "Validating options" below) -
Event Naming: Use
_onprefix for event handlers in config
import { type EventOptionKeys, Options } from '@tundralibs/utils';
type BuilderOptions = { host: string };
type BuilderEvents = { built: () => void };
class Builder extends Options<BuilderOptions, BuilderEvents> {
constructor(config: EventOptionKeys<BuilderOptions, BuilderEvents>) {
super();
this._setOptions(config);
}
setHost(host: string): this {
this._setOption('host', host); // goes through _processOption
return this; // chainable
}
build(): string {
return `connecting to ${this._getOption('host')}`;
}
}
const built = new Builder({ host: 'localhost' })
.setHost('db.internal')
.build();
console.log(built); // "connecting to db.internal"_processOption is the ONE hook every option value passes through —
override it to reject or coerce. Because _setOption (used by
setHost above, and by _setOptions internally) routes through the
same hook, a rule written here holds for the constructor AND for any
later programmatic update — unlike a check placed in the constructor
after _setOptions runs, which only guards the initial value.
import { type EventOptionKeys, Options } from '@tundralibs/utils';
type ValidatedOptions = { port: number };
type ValidatedEvents = { start: () => void };
const defaults: Partial<ValidatedOptions> = { port: 8080 };
class ValidatedServer extends Options<ValidatedOptions, ValidatedEvents> {
constructor(config: EventOptionKeys<ValidatedOptions, ValidatedEvents>) {
super();
this._setOptions(config, defaults);
}
protected override _processOption(
key: keyof ValidatedOptions,
value: ValidatedOptions[typeof key],
): ValidatedOptions[typeof key] {
if (key === 'port' && (value as number) < 1024) {
throw new Error('port must be >= 1024');
}
return value;
}
setPort(port: number): void {
this._setOption('port', port); // rejected the same way as the constructor
}
}
new ValidatedServer({ port: 3000 }); // ok
new ValidatedServer({}); // ok — default 8080
try {
new ValidatedServer({ port: 80 });
} catch (err) {
console.log((err as Error).message); // "port must be >= 1024"
}
const server = new ValidatedServer({});
try {
server.setPort(80); // rejected the same way — same hook, not bypassable
} catch (err) {
console.log((err as Error).message); // "port must be >= 1024"
}- Events - Event system (inherited)
- privateObject - Private storage (used internally)
- Config - Configuration loading