@observable Monorepo
A set of lightweight, modern reactive libraries inspired by RxJS, implementing the Observer pattern in JavaScript.
Adhere to the SOLID design principles as much as possible. We could say a lot, but will defer to myriad of online resources that outline the merits of these principles.
- Single Responsibility
- Open/Closed
- Liskov Substitution
- Interface Segregation
- Dependency Inversion
Class definitions should be expressions that leverage declaration merging and private API obfuscation to achieve pure encapsulation.
interface Example {
foo(): void;
bar(): void;
}
interface ExampleConstructor {
new (): A;
readonly prototype: A;
}
const Example: ExampleConstructor = class {
foo(): void {
this.#foo();
}
#foo(): void {
// Do something
}
bar(): void {
this.#bar();
}
#bar(): void {
// Do something
}
};All object instances, constructors, and prototypes must be frozen to prevent runtime mutation. This ensures predictable behavior and guards against accidental modification.
const Example: ExampleConstructor = class {
constructor() {
Object.freeze(this);
}
};
Object.freeze(Example);
Object.freeze(Example.prototype);All public functions and methods must validate their arguments at runtime using the standard
TypeError type.
function example(value: string): void {
if (arguments.length === 0) {
throw new TypeError("1 argument required but 0 present");
}
if (typeof value !== "string") {
throw new TypeError("Parameter 1 is not of type 'String'");
}
}All public methods must validate their 'this' instance at runtime using the standard TypeError
type.
const Example: ExampleConstructor = class {
foo() {
if (!(this instanceof Example)) {
throw new TypeError("'this' is not instanceof 'Example'");
}
}
};Validation must occur at the entry point of each public function, not delegated to shared validation helpers buried in the call stack. This keeps stack traces shallow and points errors directly to the user's call site, making debugging straightforward.
// ✓ Good: Validation at entry point produces a shallow stack trace
function map<In, Out>(transform: (value: In) => Out): (source: In[]) => Out[] {
if (arguments.length === 0) throw new MinimumArgumentsRequiredError();
if (typeof transform !== "function") {
throw new ParameterTypeError(0, "Function");
}
return function mapFn(source) {
if (arguments.length === 0) throw new MinimumArgumentsRequiredError();
if (!Array.isArray(source)) throw new ParameterTypeError(0, "Array");
// ...
};
}
// ✗ Bad: Delegating to a validation helper adds noise to the stack trace
function map<In, Out>(transform: (value: In) => Out) {
validateFunction(transform); // Adds extra frame(s) to stack
return function mapFn(source) {
validateArray(source); // Adds extra frame(s) to stack
// ...
};
}When a user passes invalid arguments, the error should point directly to their code — not to internal library plumbing.
Provide is* type guard functions for all unique public interfaces. Type guards must:
- Accept
unknownand return a type predicate - Support both class instances and POJOs
- Validate required arguments
function isUser(value: unknown): value is User {
if (arguments.length === 0) throw new MinimumArgumentsRequiredError();
return (
value instanceof User ||
(isObject(value) &&
"id" in value &&
typeof value.id === "string" &&
"name" in value &&
typeof value.name === "string")
);
}All classes must implement Symbol.toStringTag for proper object stringification.
const Example: ExampleConstructor = class {
readonly [Symbol.toStringTag] = "Example";
};
// Usage:
`${new Example()}`; // "[object Example]"Functions that accept collections
(Array,
Set,
Map, etc.)
should accept any
iterable
rather than a specific collection type. This is generally more flexible and performant.
// ✓ Good: Accepts any iterable
function sum(values: Iterable<number>): number;
// ✗ Bad: Restricts to arrays unnecessarily
function sum(values: Array<number>): number;Exception: Use specific collection types when there's a concrete performance or correctness requirement.
Functions should be pure wherever possible:
- Deterministic — Same inputs always produce the same output
- No side effects — No mutations, I/O, or external state changes
- Referentially transparent — Can be replaced with its return value
Function parameters should use descriptive names that convey their role. The following conventions are preferred (but not strictly enforced):
| Name | Purpose | Signature |
|---|---|---|
| predicate | Tests a condition, returns boolean | (value) => boolean |
| project | Transforms an input value into an output | (value) => newValue |
| reducer | Accumulates values over time | (previous, current) => accumulated |
| comparator | Compares two values for equality | (a, b) => boolean |
| callback | Called for side effects, returns void | (value) => void |
| factory | Zero-argument function that produces something | () => something |
// predicate — "should this pass through?"
filter((x) => x > 5);
// project — "transform this into that"
map((x) => x * 2);
switchMap((id) => fetchUser(id));
// reducer — "combine previous + current"
scan((total, value) => total + value, 0);
// comparator — "are these the same?"
distinctUntilChanged((a, b) => a.id === b.id);
// callback — "do something with each value"
forEach((value) => console.log(value));
// factory — "create something on demand"
defer(() => of([Date.now()]));
// notifier — "signal me when to stop"
takeUntil(destroy);All public APIs must have JSDoc documentation including:
- A description of purpose and behavior
@exampleblocks with runnable code samples
/**
* Calculates the sum of all numbers in an array.
* @example
* ```ts
* sum([1, 2, 3]); // 6
* sum([]); // 0
* ```
*/
export function sum(values: number[]): number;Tests follow the Arrange/Act/Assert pattern with descriptive test names that explain the expected behavior. Each test should focus on a single behavior.
Deno.test("sum should return 0 for an empty array", () => {
// Arrange
const values: Array<number> = [];
// Act
const result = sum(values);
// Assert
assertEquals(result, 0);
});Reusable utilities should be centralized in a dedicated internal package/module. Internal functions
that should not be exported are marked with @internal Do NOT export. in their JSDoc.
/**
* Clamps a value between a minimum and maximum.
* @internal Do NOT export.
*/
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}