See also Typescript - Trycatch wrapper - although the wrapper is less idiomatic for Javascript

Good practices

Always throw an Error - Class

The default error class in Typescript can be extended with new properties and a custom type

class DatabaseError extends Error {
    protected code: string;
    constructor(message: string, code?: string) {
        super(message);
        this.name = "DatabaseError";
        this.code = code || "0000";
        Object.setPrototypeOf(this, DatabaseError.prototype);
    }
}

Using Codes

When handling similar Errors, use codes to differentiate between different Error cases. In the DatabaseError example, this could mean using an Enumeration for several database error causes, such as ‘User not found’ or ‘Entry already exists’.

const DatabaseErrorCode = {
    UNKNOWN: "UNKNOWN",
    NOT_FOUND: "NOT_FOUND",
    CONFLICT: "CONFLICT",
    BAD_REQUEST: "BAD_REQUEST"
} as const;
 
type TDatabaseErrorCode = typeof DatabaseErrorCode[keyof typeof DatabaseErrorCode];

Then add the type to the code property of the DatabaseError class:

class DatabaseError extends Error {
    protected code: TDatabaseErrorCode;
    constructor(message: string, code?: TDatabaseErrorCode) {
        super(message);
        this.name = "DatabaseError";
        this.code = code || "UNKNOWN";
        Object.setPrototypeOf(this, DatabaseError.prototype);
    }
}

Bubble errors to upper scopes

If errors cannot be locally recovered, bubble them to the upper function, for example in the application layer.

// Database Layer
function getUser() {
    const user = null; // read user in this function
 
    if(!user) {
        throw new DatabaseError("User not found", DatabaseErrorCode.NOT_FOUND);
    }
 
    return user;
}
 
// Application Layer
function readUser() {
    try {
        getUser();
    } catch (error) {
        if (error instanceof DatabaseError) {
            if (error.code === DatabaseErrorCode.NOT_FOUND) {
                console.log(error.message);
            }
        } else {
            throw error;
        }
    }
}
 
readUser(); // 'User not found'

Implement a catchall routine

Catch unhandled errors to prevent the app to run in an inconsistent state.

Implementation in Node.js

// Synchronous exceptions
process.on('uncaughtException', err => {
	console.trace(err);
	process.exit(1);
})
 
// Asynchronous exceptions
process.on('unhandledRejection', (reason, promise) => {
	console.trace(reason);
	process.exit(1)
})

Implementation in Browsers:

// Catch all uncaught errors (synchronous & asynchronous thrown errors)
window.onerror = (message, source, lineno, colno, error) => {
  console.error('Global Error:', message, source, lineno, colno, error);
};
 
// Catch all unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
  console.error('Unhandled Promise Rejection:', event.reason);
});