
- A. The error is printed and then dropped. The Vue app that called
doSomething()is never told anything went wrong. - B. After the catch block the function falls off the end, so it resolves with
undefined. The Vue app getsundefinedwhere it expected a response, and the real failure shows up later asCannot read properties of undefinedsomewhere in a component, far from the cause.
The app is responsible for handling errors, because the app is the only layer that knows what to do about one: show a toast, redirect to login, retry, or fall back to a cached cart. The module's job is to make the error worth catching: throw it, give it a stablecode, keep the original failure attached ascause, and export the error classes so the app can tell a missing cart from a dead cart service. A library that logs and swallows takes that choice away from every project that imports it, and it hides bugs behindundefined. The pattern below is four short files, run end to end at the bottom.

Give Every Failure a Name the App Can Switch On
A plain throw new Error('not found') forces the app to match on message text, and message text changes between versions. One base class with a code and an HTTP-ish status, plus a subclass per kind of failure, fixes that for good. Node 16.9 and later accept { cause } in the Error constructor, which keeps the low-level error (the ECONNREFUSED, the Axios response) attached without leaking it into the message.
// errors.js - the one file every error in the module comes from.
class ShopError extends Error {
constructor(message, { code, status = 500, cause, details } = {}) {
super(message, { cause });
this.name = this.constructor.name;
this.code = code; // stable string the app can switch on
this.status = status; // HTTP status the app may map it to
this.details = details; // safe extra data, never secrets
}
}
class NotFoundError extends ShopError {
constructor(what, id) {
super(`${what} ${id} not found`, { code: 'NOT_FOUND', status: 404, details: { what, id } });
}
}
class ValidationError extends ShopError {
constructor(message, details) {
super(message, { code: 'VALIDATION', status: 400, details });
}
}
class UpstreamError extends ShopError {
constructor(service, cause) {
super(`${service} did not answer`, { code: 'UPSTREAM', status: 502, cause, details: { service } });
}
}
module.exports = { ShopError, NotFoundError, ValidationError, UpstreamError };
Core Functions Throw and Never Log
Inside the module, a function does one thing with an error: it turns a low-level failure into one of the typed errors above and throws it. No logger, no return null, no empty catch. The fetchCart dependency is passed in so the same code runs against Axios, fetch, or a test stub.
// cart.js - a core function. It throws typed errors and logs nothing.
const { NotFoundError, ValidationError, UpstreamError } = require('./errors');
async function getCart(cartId, { fetchCart }) {
if (!cartId) throw new ValidationError('cartId is required', { field: 'cartId' });
let response;
try {
response = await fetchCart(cartId);
} catch (err) {
throw new UpstreamError('cart-service', err); // keep the original as .cause
}
if (response.status === 404) throw new NotFoundError('Cart', cartId);
return response.data;
}
module.exports = { getCart };
Log at the Public Surface With the Logger the App Gives You
The urge to log inside the module is reasonable: you want to see failures in production. The way to get that without stealing the decision is to accept a logger from the app and default to console. Log one line, then rethrow. The app’s logger decides where the line goes (Sentry, pino, a file), and the app still gets the error.
// index.js - the module's public surface. The app injects its own logger.
const { getCart } = require('./cart');
const errors = require('./errors');
function createShop({ logger = console, fetchCart } = {}) {
return {
async getCart(cartId) {
try {
return await getCart(cartId, { fetchCart });
} catch (err) {
logger.warn?.(`[shop] getCart failed: ${err.code ?? err.name}`);
throw err; // log, then let it go up
}
},
};
}
module.exports = { createShop, ...errors };
logger.warn?.()uses optional chaining so a minimal logger with onlyerrordoesn’t crash the module.- The error classes are re-exported from
index.js, so the app writesimport { NotFoundError } from '@yourco/shop'and never reaches into the module’s folders. - If you’d rather the module stay silent, drop the try/catch in
index.jsaltogether. Rethrowing unchanged is the same as not catching.
Run It and Watch the Error Arrive Intact
This demo wires a fake fetchCart that succeeds, returns 404, and throws a connection error in turn. The output is from running it on Node 22.
const { createShop, NotFoundError, UpstreamError } = require('./index');
const shop = createShop({
logger: { warn: (m) => console.log('APP LOGGER:', m) },
fetchCart: async (id) => {
if (id === 'c-404') return { status: 404 };
if (id === 'c-down') throw new Error('ECONNREFUSED 10.0.0.5:7001');
return { status: 200, data: { id, items: 2 } };
},
});
(async () => {
console.log(await shop.getCart('c-1'));
try { await shop.getCart('c-404'); }
catch (e) { console.log(e instanceof NotFoundError, e.code, e.status, e.message); }
try { await shop.getCart('c-down'); }
catch (e) { console.log(e instanceof UpstreamError, e.code, e.status, '| cause:', e.cause.message); }
try { await shop.getCart(''); }
catch (e) { console.log(e.code, e.details); }
})();
node demo.js:
{ id: 'c-1', items: 2 }
APP LOGGER: [shop] getCart failed: NOT_FOUND
true NOT_FOUND 404 Cart c-404 not found
APP LOGGER: [shop] getCart failed: UPSTREAM
true UPSTREAM 502 | cause: ECONNREFUSED 10.0.0.5:7001
APP LOGGER: [shop] getCart failed: VALIDATION
VALIDATION { field: 'cartId' }
Three different failures, three different classes, and the app’s own logger got each line first. Compare that with the original function, which would have printed three stack traces and handed the app undefined three times.
Catch It in Nuxt Where the User Can See the Result
With the module throwing properly, the Vue side gets short. In a Nuxt page, useAsyncData already catches for you and exposes error. In a component or a Pinia store, catch once and map the code to what the user should see.
<script setup>
import {{ NotFoundError }} from '@yourco/shop'
const {{ $shop }} = useNuxtApp()
const route = useRoute()
const {{ data: cart, error }} = await useAsyncData(
`cart-${{route.params.id}}`,
() => $shop.getCart(route.params.id)
)
if (error.value instanceof NotFoundError) {{
throw createError({{ statusCode: 404, statusMessage: 'That cart is gone' }})
}}
</script>
import {{ defineStore }} from 'pinia'
import {{ UpstreamError, ValidationError }} from '@yourco/shop'
export const useCartStore = defineStore('cart', {{
state: () => ({{ cart: null, notice: '' }}),
actions: {{
async load(id) {{
try {{
this.cart = await this.$shop.getCart(id)
this.notice = ''
}} catch (err) {{
if (err instanceof UpstreamError) this.notice = 'Cart service is down, try again in a minute.'
else if (err instanceof ValidationError) this.notice = err.message
else throw err // unknown: let Nuxt's error page have it
}}
}},
}},
}})
The else throw err line matters. An app should handle the errors it understands and pass the rest up to the framework’s error boundary, the same rule the module followed one layer down.
Two Cases Where the Module Should Catch After All
The rule isn’t “never catch in a library”. It’s “never catch without giving something back”. Two exceptions come up in e-commerce code:
- Optional work. Sending an analytics event or warming a cache shouldn’t stop a checkout. Catch, log through the injected logger, carry on, and say so in the function’s name or JSDoc:
trackViewQuietly(). - Result objects for expected outcomes. A coupon that doesn’t apply isn’t an error; it’s a normal answer. Return
{ applied: false, reason: 'EXPIRED' }and keepthrowfor things that went wrong, not things that went as designed.
Keep the Same Shape When Another Stack Picks Up the Module
You mentioned other stacks later. The pattern above has nothing Vue-specific in it. An Express route maps err.status straight to res.status(err.status); a CLI prints err.message and exits with 1; a worker checks err.code === 'UPSTREAM' and requeues. The module never had to know which one it was in, and that’s the test: if the module can’t guess who’s calling, it can’t be the one that decides what to show.
