DEV Community

loading...

Creating a modern JS library: Writing good code

101arrowz profile image 101arrowz ・8 min read

It's impossible to assign a fixed definition to "good code", but most of the time in the JS world, we mean code that is:

  • bug-free
  • versatile
  • readable
  • fast
  • small

in that order. For libraries, you may choose to move readability to the bottom of the list, but that's probably not the best move if you'd like others to help you maintain your project. Now, let's see what each of these facets of "good code" entails.

Please remember, this is entirely my own opinion: feel free to ignore it entirely. Everyone should have their own definition of "best practices".

Writing bug-free code

Nobody will learn to use a new library if it has way too many bugs, no matter how good its other aspects are. The very fear of hidden bugs and untested circumstances explains why newer projects, no matter how much better than their predecessors they are, are often less popular than established libraries.

Writing tests is absolutely essential if you want to minimize the number of bugs your codebase has. Even rudimentary, seemingly pointless tests serve two purposes: they prevent you from accidentally publishing a broken version and they give your users a sense of security that their apps won't break when they update their dependencies. Whenever a new bug is reported or found, you'll want to add a test that would have failed before the bug was patched to make sure the package doesn't regress in the future.

There are a wide variety of libraries you can use to test your code. You'll need a test runner and, usually, a testing utility. For low-level or small projects, I recommend uvu as a test runner and uvu/assert as a testing utility, both of which work in either Node.js or the browser.

// test/index.js
import { test } from 'uvu';
import * as assert from 'uvu/assert';

// Import from the source file
import { myFunction } from '../src/index.js';

test('works on basic input', () => {
  assert.equal(
    myFunction({ a: 'b'}),
    'expected output'
  );
  assert.is(Math.sqrt(144), 12);

  // Throwing errors also works, so uvu works with
  // most third-party assertion libraries
  if (myFunction(123) != 456) {
    throw new Error('failed on 123');
  }
});

// Running node test/ runs these tests
Enter fullscreen mode Exit fullscreen mode

For larger projects, you'll probably prefer Jest, since it supports more advanced use cases such as snapshots. You can't as easily run Jest tests in the browser, but most UI frameworks have integrations that allow for Jest testing in Node.js.

// __tests__/index.js
import { myFunction } from '../src/index.js';

test('works on basic input', () => {
  expect(myFunction({ a: 'b'}))
    .toBe('expected output');

  expect(myFunction(123)).toMatchSnapshot();
});

// npm run jest runs the tests
Enter fullscreen mode Exit fullscreen mode

If you need more than the basic assertion tools that come with your test runner, you'll need to choose which testing utilities to use based on what your library does. I personally like the Testing Library suite, e.g. React Testing Library for React component libraries.

Beyond testing your code, it's an excellent idea to write your library in TypeScript. Type errors are among the most common type of mistake in JavaScript, so using TypeScript will almost always reduce development time and may occasionally prevent you from publishing broken code if you forget to add a test. Moreover, the excellent TypeScript compiler will allow you to avoid using a bundler when publishing your package (we'll get into this more later) and will make supporting TypeScript and JavaScript users simultaneously much easier.

TL;DR: Tests and (optionally) TypeScript

Writing versatile code

Users enjoy a feature-rich experience. A library that functions very well in doing one specific task may attract other library authors, since they want to minimize code bloat, but writing code that functions well for general-purpose tasks will bring in many more direct dependencies.

It's not really possible to give advice on what features you should add to your library since it all depends on what you're trying to achieve. However I can give advice on how to write code in a way that allow for easy future expansion. Here are a few suggestions:

  • Avoid creating short, single-use functions unless you plan to use them again in the near future. Splitting up a function may make the code look nicer, but it makes maintaining and tracking changes to that code more difficult. You can ignore this if the single-use function is very long.
// Don't do this:
const rand = (a, b) => {
  // If you decide to change this in the future (e.g. adding
  // a third argument for random number generation) you will
  // need to modify two functions instead of one.
  const randfloat = Math.random();
  return a + Math.floor(randfloat * (b - a));
}

const randArrayInRange = (len, a, b) => {
  const arr = new Array(len);
  for (let i = 0; i < len; ++i) {
    arr[i] = rand(a, b);
  }
  return arr;
}

// Use a single function, but make sure to add comments where
// you would otherwise have called a helper function.
const randArrayInRange = (len, a, b) => {
  const arr = new Array(len);
  for (let i = 0; i < len; ++i) {
    // Generate random number at least 0, less than 1
    const randfloat = Math.random();
    // Move randfloat into [a, b) range
    arr[i] = a + Math.floor(randfloat * (b - a));
  }
  return arr;
}
Enter fullscreen mode Exit fullscreen mode
  • Add TODO comments whenever you notice something that could become an issue in the future. Doing so will save you time when you decide to add a feature that initially fails because of prior decisions or oversights.
const numPostsOnPage = async page => {
  // TODO: "page" may not be the name of the argument in the
  // calling function - can be ambiguous
  if (typeof page != 'number') {
    throw new TypeError('page must be a number');
  }
  const resp = await fetch(`//example.com/page/${page}`);
  const posts = await resp.json();
  return posts.length;
}

const example = (x, y) => {
  if (typeof x != 'number') {
    throw new TypeError('x must be a number');
  }
  // TODO: This is an async function, so a type error for y
  // will not throw but will reject the returned Promise,
  // but a type error for x throws
  return x * numPostsOnPage(y);
}

// Because of the TODOs, in the future, you'll easily
// find why the type error for y isn't caught here
try {
  example(0, 'mistake');
} catch(e) {
  console.error(`Got error: ${e}`);
}
Enter fullscreen mode Exit fullscreen mode
  • Use documentation for code that you will consider modifying in the future. Even if the code is only used internally, this will make modifications easier and will help collaborators diagnose bugs more easily.
// TODO: in the future, consider changing the following
// recursive function to be more efficient by fetching
// all users simultaneously with Promise.all()

// gets the names of all users
const getUserNames = async max => {
  // Recursive base case - no user 0 exists
  if (!max) return [];
  const res = await fetch(`/users/${max}`);
  // Data for user ID # max
  const userData = await res.json();
  // Prepend data for users with lower IDs
  return (await getUserNames(max - 1)).concat(userData);
}
Enter fullscreen mode Exit fullscreen mode

TL;DR: Keep your codebase maintainable and everything will fall into place

Writing readable code

Readable code is critical for maintainability and for receiving help from the community. Nobody wants to spend an hour studying your codebase just to understand what each function does; writing easy-to-read code is a good start.

This step is incredibly simple. The two things you need to do are:

  • Use sufficient (but not too much) inline documentation for functions, variables, etc.
  • Additionally, use self-documenting function/variable names for user-facing code (i.e. what is exported). Optimally, clean JSDoc will accompany each declaration (using JSDoc/TSDoc will be very helpful, as we'll see in a future article).
// The short names used here are OK because they are
// documented and because the names make sense

// zip compression worker
// send string -> Uint8Array mapping
// receive Uint8Array ZIP data
const zwk = new Worker('./zip-worker.js');

// read file to [filename, Uint8Array]
const readFile = file => new Promise((resolve, reject) => {
  // file reader: File to ArrayBuffer
  const fr = new FileReader();
  fr.onload = () => {
    // fr.result is ArrayBuffer
    resolve([file.name, new Uint8Array(fr.result)]);
  }
  fr.onerror = () => {
    reject(fr.error);
  }
  fr.readAsArrayBuffer(file);
});

/**
 * Zips the provided files
 * @param files {File[]} The files to create a ZIP from
 * @returns {Promise} A promise with a Blob of the ZIPped data
 */
export async function zipFiles(files) {
  // file entries - Array of [filename, data]
  const entries = await Promise.all(files.map(readFile));
  // transferable list - neuters data passed in but reduces
  // execution time
  const tfl = fileEntries.map(([, dat]) => dat.buffer);
  // filename -> data mapping
  const fileData = fileEntries.reduce((obj, [fn, dat]) => {
    obj[fn] = dat;
    return obj;
  }, {});

  return new Promise((resolve, reject) => {
    zwk.onmessage = ({ data }) => resolve(data);
    zwk.onerror = ({ error }) => reject(error);
    zwk.postMessage(fileData, tfl);
  });
}
Enter fullscreen mode Exit fullscreen mode

TL;DR: Make it self-documenting or document it yourself

Writing fast code

This isn't meant to be a performance article, so I'm not going to go into too much depth here.

For low-level code (i.e. anything involving bit twiddling, binary encoding, etc.), you'll want to use the profiler in Node.js (your code editor may have support) or Chrome (see this article). This guide to performance in the V8 engine may help.

For higher level programs such as UI libraries and frameworks, micro-optimizations are pointless. Look for large-scale architectural issues with your design (for example, needing to call document.getElementById multiple times per second due to a limitation in your virtual DOM). The Chrome profiler will also help determine if the issue lies with your JavaScript, rendering, or something else.

TL;DR: If this section is too long, it probably doesn't apply to you.

Writing small code

Again, this article isn't meant to be about optimization, so I won't discuss much here, but let me know in the comments if you'd like a more detailed write-up on how to squeeze every last drop of performance out of your code.

Small code can contribute to both readability and to performance (i.e. load times in the browser). However, if you're writing a library for Node.js only, small code is not a concern at all unless you have so much code bloat that your codebase is difficult to understand. In general, small code is the least important facet of a good library.

If you'd really like to shrink the size of your bundled code, the best way is to avoid using pre-built abstractions for things you can implement manually. For instance, if you need to get the duration of a song in an MP3 file in the browser, don't use music-metadata, do it yourself. The code you need to write is probably around a few hundred bytes, so you'll save 63 kB.

TL;DR: Do everything yourself

That's it!

At the end of the day, how useful a library is depends the most on how difficult it is to work around the problem it solves. Nobody wants to write a SHA-256 algorithm from scratch, so even unmaintained cryptography libraries are very popular. On the other hand, DOM manipulation libraries are a dime a dozen, so even some excellent UI frameworks receive very few downloads. However, good code is very appreciated no matter how many people are using it. I hope these tips were helpful. Thanks for reading!

Discussion (1)

Collapse
bassochette profile image
Julien Prugne

Very good, I am not on the exact same page on every thing but that is definitely a work ethic I can work with.

Forem Open with the Forem app