Vladimir Klepov as a Coder

Build better libraries, use dev warnings

Suppose you're making a cool library that sums numbers in an array. You add a new option, inital, that lets users specify an initial value for the summation:

sum([1, 1, 1], { inital: 10 }) // 13

Oh no! You made a typo — of course you meant initial, not inital. What's done is done, and you're stuck with a million users relying on your inital option. Here's what you can do:

  1. Keep the inital option forever. You bconfuse the users and become known as that guy who can't spell.
  2. Rename inital to initial immediately. Everyone has to rewrite their code that was working fine (thinking you're a jerk), and the apps whose authors don't follow the changelog explode.

As a responsible maintainer, you decide to go the third way — support both initial and inital for now, schedule dropping inital in v2, and let your users know a breaking change is coming. You fix the issue with this ingenious code:

function sum(arr, ops = {}) {
if ('inital' in ops) {
console.log('dont use inital option');
const { initial = (ops.inital || 0) } = ops;
return arr.reduce((s, a) => s + a, initial);

Not so fast! Here are some problems with this fix:

  1. Production bundle size of your library has grown by 25% thanks to the bundled error messages.
  2. The apps relying on inital option run slower, since console.warn is fairly heavy.
  3. Dev console is all covered in your inital message, and it's easier to miss important warnings from other libraries.
  4. If the dev uses a lot of libraries, it's not clear exactly what caused the error.

Let's handle these issues one by one.

Remove warning from production

The first two issues can be fixed by removing the warning code from production bundle. Easier than it seems:

if (process.env.NODE_ENV === 'development' && ('inital' in ops)) {
console.log('dont use inital option');

Your user's bundler replaces process.env.NODE_ENV with a literal string, "production", in production mode, turning the condition into if ('production' === 'development'), which is if (false), and then the minifier's dead code elimination removes the if block altogether, since it can never get executed. All the warning code, and even the ops.inital check, are gone. Pretty smart!

This works in webpack, parcel, and rollup (with plugin-replace). Some of the biggest JS libraries, like react and vue, use this technique, so you're in good company.

One nasty thing is that you must wrap the warning in a full process.env.NODE_ENV === 'development' condition manually each time — using any indirection may confuse the replacement algorithms and prevent code removal:

// devWarn becomes () => {}
function devWarn(msg) {
process.env.NODE_ENV === 'development' && console.log(msg);
// but the call with the string is not removed
devWarn('oh no');

// looking across module boundaries may not work
import { isDev, env, dev } from '../env';
isDev && console.log('ooh no');
(process.env.NODE_ENV === dev) && console.log('no');
(env === 'development') && console.log('no');

Error tracing

Modern web apps use many different libraries. When a developer sees your message, it may not be obvious where it came from. A good first step is replacing console.log with a warn or error that fires with a nice expandable stack trace:

In some frameworks (looking at you, React) the stack trace may not be that useful. If that's your case, provide extra identification inside the message:

console.warn('[sum/useSum] dont use inital')

Wording the warnings is important, too. Our current "inital is bad" is confusing — what's wrong with that option? Is my code broken? What should I do? Make sure to provide the motivation for the change and an actionable fix. Here: we made a typo, the code is OK until v2, please move to an option with a normal name when you have the time. Here we go: "inital" option was a typo and will be removed in v2 - use "initial" Feel free to provide a link to relevant docs / discussions if it's a particularly complicated matter. Remember, the messages are removed from production bundle, so there's no reason to save keystrokes here.

Log once

Don't assume your users are stupid and bombard them with warnings — it's annoying and may drown other, more important, messages. I prefer showing every warning once — the users who care will clean up their code until no more warnings are left, and those who don't are free to go on with their business. Here's a way to do that:

function warnOnce() {
const logged = {};
return (msg) => {
if (!logged[msg]) {
logged[msg] = true;
export const warn = warnOnce();

If you a particular warning really needs to fire several times, play around with warnOnce instancing — like useMemo(warnOnce, []) to warn once per React component instance.

Dev warnings are not only helpful for deprecations and breaking change announcements. Incorrect API uses deserve a warning, too — for example, the user could pass two conflicting options, or an obscure error would be thrown soon and you'd like to explain what caused it. Most dev warnings are a sign of bad API design, but they're a helpful tool nonetheless.

Here are some tips for great dev warnings:

  • Wrap your dev warnings into a process.env.NODE_ENV === 'development' condition to strip them from the production bundle. No abstractions here, please.
  • Make sure the source of the error is clear — use console.warn / error to show a nice stack trace and include the library name in the message itself.
  • Clearly explain what caused the warning, the consequences of ignoring it, and suggest a fix.
  • Don't drown the users in warnings — warn once.

Hopefully, these tips will help you improve your library developer experience. Warning is caring.

Hello, friend! My name is Vladimir, and I love writing about web development. If you got down here, you probably enjoyed this article. My goal is to become an independent content creator, and you'll help me get there by buying me a coffee!
More? All articles ever
Older? useLayoutEffect is a bad place for deriving state Newer? Zero-setup bundle size checker