Back Send feedback to ilkka.kuivanen@me.com

My Journey to a Better JavaScript Module

Intro

This post is about my approach to authoring standard Javascript module. There is no shortage of resources available and I recommend checking at least MDN module guide as a primer. All opinions are my own. The code provided here might be broken or might not work as intended. No guarantees of any sorts. I hope this works as inspiration, thought piece or even reference for someone working on the same area.

The Journey (so far)

After some years of building JavaScript apps (in Typescript), I’ve seen things done in many different ways. My own approach and opinions have shifted several times on the same topics.

Disclaimer: I am not after debating the characteristics of the JavaScript language itself. I’m focused on getting things done — within the known constraints.

My approach is guided by a few key principles, applicable across all contexts – whether hobbies, professional work, experiments, or production-level applications:

Reducing the needs even further to get to the essence:

These two guiding intentions is the basis of the work presented in this work. In CRUD terms "be fast" is about creating new code or updating and deleting existing code. "Be understandable" is about reading. Being fast becomes possible when things are understandable. So, the primary goal is to make everything as understandable as possible. Is this understandable?

Let's start.

My challenges (so far)

A great module is logical to follow, easy to change, isolated, error resistant and has nice API for consumers.

— Ancient Finnish proverb

Here are the things I consider the most challenging, or at least the areas that have caused me frustration:

But wait, why modules and not classes? Modules are inferior to classes in many ways, for instance one can't instantiate modules and many of the concepts mentioned here relates to classes as well. So, why am I not talking about just classes?

There is strong conceptual overlap on how I see Javascript modules and classes and their purpose although there are huge differences. I think Javascript modules, used as abstraction containers, are conceptually better than classes. They are easier to read and write. They are more primitive. They don't have the fixed extra layer of abstraction sprinkled on top of them and they don't implicitly carry the OOP baggage. And in context of Javascript – the language – they are not real classes (but that's beside the point).

Let's address the issues one at the time.

1: Make the high-level file structure understandable

In context of splitting code: in order to make things understandable one most resist the urge to be clever.

— Old wisdom from Lapland

File structures – and filenames – are usually related to the underlying software design pattern used, even if the author did not use any pattern knowingly. There are more formal design patterns, such as adapter, facade, proxy, service, factory, etc. I don't think it makes sense to predescribe which patterns are allowed or how they should be applied. Rather, I think it makes sense to have logical relation between modules utilising these patterns. Conceptually, an easy to understand way is to split modules into hierarchical structure. Either the module is the "main thing" or it supports whatever is the thing main thing above it. Consider the following:

-   integration.module.ts
    -   api.module.ts
    -   transform-responses.module.ts
-   server-state.module.ts
-   auth.module.ts

(Note: I am not proposing any naming convention here.)

It should go without saying that sanest approach is to limit the depth in the hierarchy. I think two-level is almost always enough. I recommend avoiding the tempation of creating generic modules that can be "injected" or used dynamically as children. If two different modules have similar need for a sub-module then just write two sub-modules with partially overlapping code. The ideal of reusability does not shine from the updating perspetive. If the business logic is not finalised and things needs to be changed later it is usually unnecessarly large effort to rethink the dynamic part. This relates to the art of optimisation, which is not the topic of this post, but still relates to the underlying challenge. It's about not getting too fancy and having a good taste for what is understandable.

So what about the boundaries of a module? How to design them and what is the right level of abstraction? I like to start designing the interface by typing out the API of the module you want to have. By starting the design in consumer-centric manner it often becomes relatively obvious what is the main point of a module, or should it even exist.

I imagine some TDD people would probably get excited here and go further with this, but I think it's more than testability of the code. This is important because no module should exist without a reason. There are no valid reasons for non-semantic lexical separation of code. In other words, if your module is getting too large, it should not be fixed by splitting the file as-is. It requires logical restructuring to make it make sense. The module has it's purpose in the world and it's your responsibility to define it.

Okay so, let's say you have figured out the boundaries and purpose of your module, then you might ask what about types and other shared assets? There are many ways to structure your application in literal sense, be that frontend or backend part of the code. Here's what I suggest in practice:

Consider the following example:

/modules
  └── auth/
      ├── auth.service.ts      <- uses local + shared module types
      ├── auth.controller.ts   <- uses local + shared module types
      └── types.ts             <- shared just within auth module

/types
  └── api.ts                   <- shared across modules

/../shared-package/types
  └── user.types.ts            <- shared across applications (e.g. backend and frontend)

Key point: make the reason for the module existence be unambigious and justified.

2: Make timing and order of startup understandable

Never leave the moment initialisation of a module and it's content to chance.

— Swedish folklore

The initialisation of module and it's content might turn out to be more complicated that initially thought. Javascript engine has a detailed process for parsing through the dependency chain and initialising stuff in order to make the content executable. Things might become quite complex especially if your module has local variables. One of the not-so-obvious things is temporal dead zone (TDZ).

There are many valid cases for a module to have top-level local variables that are not wrapped in functions. In order to use them safely and to avoid issues related timing I use an approach that always uses explicit initialisation and getter functions for accessing the local variables. Module's local variables should be set to null by default. The accessor functions throws an error if the variable has not been initialised. This makes enforces two things: structured initialisation of your application's modules (e.g. in app.ts) and clarity on when is module content's accessed.

It looks like this:

let paths: Paths = [];
let isInitialized = false;
function initPublisher(options: PublisherOptions): void {
if (!options.paths || options.paths.length === 0) {
throw new Error("Paths are required for initialization");
}
paths = options.paths;
isInitialized = true;
}
function getPublisher(): boolean {
if (!isInitialized) {
throw new Error(
"Publisher accessed before initialization. Call initPublisher first."
);
}
return isInitialized;
}
function startPublisher(): void {
if (!isInitialized) {
throw new Error(
"Publisher accessed before initialization. Call initPublisher first."
);
}
// Add logic
}
function stopPublisher(): void {
if (!isInitialized) {
throw new Error(
"Publisher accessed before initialization. Call initPublisher first."
);
}
stopWatching();
}
export { initPublisher, startPublisher, stopPublisher, getPublisher };
view raw module_api.ts hosted with ❤ by GitHub

On fun thing (it's debatable) to do in order to understand your application better is to remove the initialisation from the root file and see when the application crashes when it attempts to access undefined variables. In more complex event driven systems this might different than your mental model is. This rather simple convention has saved me a lot of time, especially when things are refactored and startup order has changed. When the timing is under control the module does not really care about when it's imported by whom. It only cares about the time it is initialised and the lifecycle onwards. If you want add some extra you can also throw error if the initialisation is invoked more than once.

To restate: I think a module should not care about who imports it – as it cannot control it – but it should be extremely careful of it's own imports. If the module imports other than library code (i.e. npm packages) be aware of the possibility of circular dependencies. The import is the most important statement of the module: it implies the structure and purpose of your module in relation to other parts of the code. In case you end up with circular dependencies it is not the end of the world and there are rather straightforward approaches to resolve them (e.g. lift up or extract the common logic). It only becomes bigger problem if it sneaks in early and you realise much later through some weird issues.

Key point: use explicit initialisation and getter functions to be in control of the timing.

3: Make moments of error understandable

Things go wrong all the time so it's just matter of making it evident when it happens. Use Errors as values when possible. Limit throwing to the scope of your module.

— Norwegian saying

Module that handles async functions – such as external requests – should return errors in expected format. The most obvious way is to type out the responses with discriminated unions of two types with properties: ok, data and error. In practise, the response is either

Here's how it looks in pracise:

interface ModuleResponseSuccess<T> {
ok: true;
data: T;
}
interface ModuleResponseError {
ok: false;
error: Error;
}
type ModuleResponse<T> = ModuleResponseSuccess<T> | ModuleResponseError;

But what about the errors themselves?

Here's what I propose: make yourself your own error:

For this purpose I have written Better Error. It make the standard error object a bit better and handier to use. The class BetterError can be extended to more specific error class, or just use as is. Depending on your needs. I recommend extending if more than couple of different types of error you want to manage.

Code:

// Module: BetterError
// Author: ilkka.kuivanen@me.com
// Date: 2025-07-27
// Version: 0.1.0
// License: MIT
// Description:
// - An attempt to make errors a bit better by allowing them to carry additional context information.
// - Bonus: An util function that provides a way to handle fetch responses with better error handling.
// Contents:
// - BetterError class -------------- class for creating errors with context
// - ErrorParams interface ---------- type for defining error parameters
// - DefaultErrorContext type ------- type for default context structure
// - LogObject type ----------------- type for structured logging
// - ErrorLogger type --------------- type for custom error logging
// - handleFetchResponse function --- util for handling fetch responses with enhanced error handling
type LogObject = {
level: string;
name: string;
createdAt: string;
message: string;
context: unknown;
stack: string | undefined;
cause: unknown;
};
type ErrorLogger = { error: (logObject: LogObject) => void };
type DefaultErrorContext = Record<string, unknown>;
interface ErrorParams<T = DefaultErrorContext> {
message: string;
context?: T | undefined;
// Typescript has built-in type for ErrorOptions, we are using it here.
options?: ErrorOptions;
}
/**
* Error class that can carry additional context information.
* Useful for debugging and providing more detailed error information.
*
* @template T - The type of the context object, default signature is Record<string, unknown>
*
*/
class BetterError<T = DefaultErrorContext> extends Error {
readonly context: T | null;
readonly createdAt: Date;
private static logger?: ErrorLogger;
constructor(params?: ErrorParams<T>) {
super(params?.message || "An error occurred", {
cause: params?.options?.cause
});
this.name = this.constructor.name;
this.context = params?.context ?? null;
this.createdAt = new Date();
// Compatibility: maintain proper stack trace for debugging
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
/**
* Create an error from another error with added context
* @param error - Original error
* @param context - Context to add
* @returns New ErrorWithContext instance
*/
static fromError<T = DefaultErrorContext>(
error: Error,
context?: T
): BetterError<T> {
return new BetterError<T>({
message: error.message,
context,
options: { cause: error }
});
}
/**
* Get the context object
* @returns The context object or null if no context was provided
*/
getContext(): T | null {
return this.context;
}
/**
* Get a specific value from the context with type safety
* @param key - The key to retrieve from the context
* @returns The value associated with the key, or null if not found
*/
getContextValue<K extends keyof T>(key: K): T[K] | null {
if (
this.context &&
typeof this.context === "object" &&
this.context !== null &&
key in this.context
) {
return this.context[key];
}
return null;
}
/**
* Check if the error has context
* @returns Type guard indicating if context exists
*/
hasContext(): this is BetterError<T> & { context: T } {
return this.context !== null;
}
toLogObject(): LogObject {
return {
level: "error",
name: this.name,
createdAt: this.createdAt.toISOString(),
message: this.message,
context: this.context,
stack: this.stack,
cause: this.cause
};
}
static registerLogger(logger: ErrorLogger) {
BetterError.logger = logger;
}
log() {
if (BetterError.logger && BetterError.logger.error) {
BetterError.logger.error(this.toLogObject());
} else {
console.error(
"[BetterError - No custom logger registered]:",
this.toLogObject()
);
}
}
}
type HandleFetchOptions = {
/** Whether to throw an error instead of returning it in the result object */
throwOnError?: boolean;
};
type BetterFetchResult<T> =
| { ok: true; data: T }
| { ok: false; error: BetterError };
/**
* Handles fetch responses with enhanced error handling and context preservation.
*
* This function processes HTTP responses and returns a structured result that either
* contains the parsed data (on success) or a detailed BetterError (on failure).
* It captures response bodies from both successful and failed requests for debugging.
*
* @template T - The expected type of the response data
* @param res - The Response object from a fetch request
* @param options - Configuration options for error handling
* @returns Promise resolving to either success data or detailed error information
*
* @example
* ```typescript
* const response = await fetch('/api/users');
* const result = await handleFetchResponse<User[]>(response);
*
* if (result.ok) {
* console.log('Users:', result.data);
* } else {
* console.error('Error:', result.error.message);
* console.log('Status:', result.error.getContextValue('statusCode'));
* }
* ```
*/
async function handleFetchResponse<T = unknown>(
res: Response,
options?: HandleFetchOptions
): Promise<BetterFetchResult<T>> {
const url = res.url;
// Handle successful responses (2xx status codes)
if (res.ok) {
try {
// Attempt to parse response as JSON
const data = (await res.json()) as T;
return { ok: true, data };
} catch (jsonError: unknown) {
// Even successful responses can have invalid JSON
// This creates a detailed error with context for debugging
const error = new BetterError({
message: `Failed to parse JSON from ${url}`,
context: {
url,
responseStatus: res.status,
code: "JSON_PARSE_ERROR",
statusCode: res.status,
isRetryAble: false // JSON parsing errors are not retryable
},
options: { cause: jsonError }
});
if (options?.throwOnError) {
throw error;
}
return { ok: false, error };
}
}
// Handle error responses (4xx, 5xx status codes)
// Many APIs return useful error information in the response body
// even for error status codes, so we attempt to capture it
let body: unknown;
try {
const text = await res.text();
// Try to parse as JSON first (common for API error responses)
// If that fails, keep the raw text (useful for HTML error pages, etc.)
body = tryParseJSON(text) ?? text;
} catch {
// If we can't even read the response body, set to undefined
// This can happen with network issues or malformed responses
body = undefined;
}
// Create a comprehensive error with all available context
const error = new BetterError({
message: `HTTP ${res.status} ${res.statusText}`,
context: {
url,
responseStatus: res.status,
code: `HTTP_${res.status}`,
statusCode: res.status,
// 5xx errors are typically server issues and may be retryable
// 4xx errors are client issues and usually not retryable
isRetryable: res.status >= 500,
body // Include any error details from the response
}
});
if (options?.throwOnError) {
throw error;
}
return { ok: false, error };
}
/**
* Safely attempts to parse a string as JSON without throwing errors.
*
* @param text - The string to parse as JSON
* @returns The parsed JSON object/value, or null if parsing fails
*/
function tryParseJSON(text: string): unknown | null {
try {
return JSON.parse(text);
} catch {
return null;
}
}
export {
type ErrorParams,
type DefaultErrorContext,
type LogObject,
type ErrorLogger,
BetterError,
handleFetchResponse
};
view raw better-error.ts hosted with ❤ by GitHub

Usage:

import { ErrorParams, BetterError, handleFetchResponse } from "./better-error.js";
// ******************************************
// Example usage of BetterError with context
// ******************************************
interface UserErrorContext {
userId: number;
operation: string;
timestamp: Date;
}
// An example of extending BetterError with a specific context type
class UserOperationError extends BetterError<UserErrorContext> {
constructor(params: ErrorParams<UserErrorContext>) {
super(params);
}
}
const userOperationError = new UserOperationError({
message: "User deletion failed",
context: {
userId: 123,
operation: "delete",
timestamp: new Date().toISOString(),
},
options: { cause: new Error("Database connection failed") },
});
// Type-safe context access
const userIdFromContext = userOperationError.getContext()?.userId ?? null; // number | null
const operationFromContext = userOperationError.getContextValue("operation"); // string | null
// Type guard validation
let safelyRetrievedUserId: number | null = null;
if (userOperationError.hasContext()) {
safelyRetrievedUserId = userOperationError.context.userId;
}
console.log(userOperationError instanceof UserOperationError); // true
console.log(userIdFromContext === 123); // true
console.log(operationFromContext === "delete"); // true
console.log(safelyRetrievedUserId === 123); // true
console.log(userOperationError.message === "User deletion failed"); // true
// ************************************
// Example usage of handleFetchResponse
// ************************************
const response = await fetch("https://some.api/test");
const result = await handleFetchResponse<SomeExpectedTypeFromApi>(response);
if (!result.ok) {
console.log(result.error instanceof BetterError); // true
console.log(result.error.message); // e.g. "Failed to parse JSON from https://some.api/test"
console.log(result.error.getContextValue("code")); // e.g. "JSON_PARSE_ERROR"
console.log(result.error.getContextValue("statusCode")); // 200
console.log(result.error.getContextValue("isRetryAble")); // true | false
} else {
// Result is ok! Handle response data via result.data
}

There are cases when throwing and not catching an error within the module is justified. I see that as a statement: when x happens, I don't think you (the consumer)should continue running. If you want to continue running, you really need to address it yourself, but I don't recommend it.

Key point: use errors as values in your module so your consumers know what they are getting. Don't make me catch stuff you should had caught yourself.

4: Make tests so changes to the module are understandable

NodeJS has pretty nice test runner built-in.

— Viking lore

This will be rather short. This point is actually not about the philosophy and conventions of the module as much as it is about having a structured approach to actually working with it. This post is not about promoting testing or attempting to describe the best way to approach tests. Rather, what I think is important and worth saying in this context is: write tests that appreciate the purpose of your module. If the API (your exports) is the promise of your module's logic, at least make sure it delivers on that. I really like the fact that Node has its own test runner (no deps needed), and in the advent of language models, it's now a matter of a few clicks to get some basic tests running. Even in some rapid prototypes some simple tests that validates the expected logic improves the overall speed of moving forward. Modules – and their APIs – are good abstraction to focus on when thinking what to test in the first place.

The canonical Javascript module

Below is my take on how a module should be written. This example module is written for my hobby project to manage env files. It attempts to incorporate all the areas discussed here and reflect how I see building modules.

In summary:

import { BetterError, ErrorParams } from "../../utils/better-error.js";
// ******************************************
// Types & Constants
// ******************************************
// Define the environment variable validators.
// This is a single source of truth for environment variables.
const ENV_VALIDATORS = {
NODE_ENV: (value: string) => {
const validEnvs = ["development", "production", "test"] as const;
if (!validEnvs.includes(value as (typeof validEnvs)[number])) {
throw new Error(`NODE_ENV must be one of: ${validEnvs.join(", ")}`);
}
return value;
},
PATH_TO_FILES: (value: string) => {
// No specific validation, just return the value
return value;
},
DEMO_MODE: (value: string) => {
const boolString = value.toLowerCase();
if (boolString === "true") return true;
if (boolString === "false") return false;
throw new Error(`DEMO_MODE must be "true" or "false", got: ${value}`);
},
PORT: (value: string) => {
const num = parseInt(value, 10);
if (isNaN(num) || num <= 0) {
throw new Error(`PORT must be a positive number, got: ${value}`);
}
return num;
}
} as const;
// Derive ENV_PROPS from validators
const ENV_PROPS: Record<
keyof typeof ENV_VALIDATORS,
keyof typeof ENV_VALIDATORS
> = Object.keys(ENV_VALIDATORS).reduce(
(acc, key) => {
acc[key as keyof typeof ENV_VALIDATORS] =
key as keyof typeof ENV_VALIDATORS;
return acc;
},
{} as Record<keyof typeof ENV_VALIDATORS, keyof typeof ENV_VALIDATORS>
);
// Create type for the ENV_PROPERTIES programmatically - infer return types from validators
type Env = {
[K in keyof typeof ENV_VALIDATORS]: ReturnType<(typeof ENV_VALIDATORS)[K]>;
};
// ******************************************
// State
// ******************************************
let publicEnv: Env | null = null;
// ******************************************
// Core Functions
// ******************************************
/**
* Initializes the environment configuration by reading and validating
* all required environment variables from process.env.
*
* @throws {EnvError} When environment variables are already initialized
* @throws {EnvError} When required environment variables are missing
*
* @example
* ```typescript
* initEnv();
* const env = getPublicEnv();
* console.log(env.NODE_ENV);
* ```
*/
function initEnv(): void {
if (publicEnv) {
throw new EnvError({
message: "Environment variables already initialized"
});
}
// Read-in and validate that all required environment variables are set
publicEnv = readEnvFromProcess();
}
/**
* Reads and validates environment variables from process.env.
* Throws an error if any of the required environment variables are not set.
*
* @returns An object containing the validated environment variables
* @throws {EnvError} When required environment variables are missing
*/
function readEnvFromProcess(): Env {
const env = {} as Record<string, unknown>;
const missingVars: string[] = [];
for (const property of Object.keys(ENV_PROPS) as Array<
keyof typeof ENV_PROPS
>) {
const envVarName = ENV_PROPS[property];
const value = process.env[envVarName];
if (value) {
// Apply validation and transformation
env[property] = ENV_VALIDATORS[property](value);
} else {
missingVars.push(envVarName);
}
}
if (missingVars.length > 0) {
throw new EnvError({
message: `Missing required environment variables: ${missingVars.join(", ")}`,
context: { missingVariables: missingVars }
});
}
return env as Env;
}
/**
* Retrieves the environment configuration after env variables have been initialised.
*
* @returns The validated environment configuration
* @throws {EnvError} When environment variables haven't been initialized
*/
function getPublicEnv(): Env {
if (!publicEnv) {
throw new EnvError({
message: "Environment variables not initialized"
});
}
return publicEnv;
}
// ******************************************
// Error (extending BetterError)
// ******************************************
interface EnvErrorContext {
missingVariables: string[];
}
// Extending BetterError with a specific context type
class EnvError extends BetterError<EnvErrorContext> {
constructor(params: ErrorParams<EnvErrorContext>) {
super(params);
}
}
// ******************************************
// Public API
// ******************************************
export { initEnv, getPublicEnv, ENV_PROPS };
export type { Env };
view raw env.module.ts hosted with ❤ by GitHub

Key points