Writing code for yourself and writing code for other people are different jobs. A library's users cannot read your mind or your source; they see its name, its arguments, its errors and its documentation. In this final lesson you turn the dashboard's filtering and sorting logic into a small package, and along the way meet the decisions every library author faces.
Start from the caller
Before writing any code, write the call you want people to make. The dashboard needs to filter, search, sort and limit a list of objects:
import { query } from '@your-name/list-query';
const results = query(courses, {
where: { topic: 'javascript' },
search: 'async',
searchFields: ['title'],
sortBy: 'updated',
order: 'desc',
limit: 10,
});
Decisions this example makes:
- One main function with a clear verb. Callers learn one name. A small surface is easier to document, test and keep stable.
- Data first, options second. The thing being worked on is a positional argument; everything optional goes in an options object. Named options are self-describing at the call site, can be given in any order, and new ones can be added later without breaking anyone.
- Plain data in, plain data out. Arrays of objects in, a new array out. No classes to instantiate, no hidden state.
Compare a positional version: query(courses, 'javascript', 'async', ['title'], 'updated', true, 10). Nobody can read that without the documentation open.
Defaults
Every option should have a sensible default, so the simplest call does the obvious thing: query(courses) returns a copy of all courses in their original order. Defaults belong in one visible place, the parameter list:
export function query(items, {
where = {},
search = '',
searchFields = [],
sortBy,
order = 'asc',
limit = Infinity,
} = {}) {
// ...
}
The = {} after the destructuring pattern lets callers leave the options out entirely.
Validate at the boundary, with useful errors
A library is called by code you have never seen. Check inputs at the public entry point and fail early, with a message that says what was expected, what was received and where:
const ORDERS = ['asc', 'desc'];
function describe(value) {
return Array.isArray(value) ? 'an array' : value === null ? 'null' : typeof value;
}
function validate(items, { searchFields, order, limit }) {
if (!Array.isArray(items)) {
throw new TypeError(`list-query: query() expects an array of items, received ${describe(items)}.`);
}
if (!Array.isArray(searchFields)) {
throw new TypeError(`list-query: "searchFields" must be an array of property names, received ${describe(searchFields)}.`);
}
if (!ORDERS.includes(order)) {
throw new RangeError(`list-query: "order" must be "asc" or "desc", received ${JSON.stringify(order)}.`);
}
if (limit !== Infinity && (!Number.isInteger(limit) || limit < 0)) {
throw new RangeError(`list-query: "limit" must be a non-negative integer, received ${String(limit)}.`);
}
}
Good error messages share four habits:
- Name the library and function, so the error is easy to trace in a big application.
- Say what was expected and what was received. "received a string" is the clue most people need.
- Use the right error type.
TypeErrorfor the wrong kind of value,RangeErrorfor a value outside the allowed set. - Throw, do not log. A
console.warnis easy to miss; an exception stops the bug at its source.
Validate only the public boundary. Internal helpers can trust their inputs.
The implementation
// src/index.js
/**
* Filter, search, sort and limit an array of objects without changing it.
*
* @template T
* @param {T[]} items The objects to query.
* @param {object} [options]
* @param {Partial<T>} [options.where] Exact matches required on these properties.
* @param {string} [options.search] Case-insensitive text to look for.
* @param {(keyof T)[]} [options.searchFields] Properties searched for `search`.
* @param {keyof T} [options.sortBy] Property to sort by.
* @param {'asc' | 'desc'} [options.order] Sort direction. Default 'asc'.
* @param {number} [options.limit] Maximum number of results.
* @returns {T[]} A new array.
*/
export function query(items, options = {}) {
const {
where = {},
search = '',
searchFields = [],
sortBy,
order = 'asc',
limit = Infinity,
} = options;
validate(items, { searchFields, order, limit });
const text = String(search).trim().toLowerCase();
const conditions = Object.entries(where);
let result = items.filter((item) =>
conditions.every(([key, value]) => item[key] === value) &&
(text === '' || searchFields.some((field) =>
String(item[field] ?? '').toLowerCase().includes(text)))
);
if (sortBy !== undefined) {
const direction = order === 'desc' ? -1 : 1;
result = result.toSorted((a, b) => compareValues(a[sortBy], b[sortBy]) * direction);
}
return limit === Infinity ? result : result.slice(0, limit);
}
function compareValues(a, b) {
if (typeof a === 'number' && typeof b === 'number') return a - b;
return String(a ?? '').localeCompare(String(b ?? ''));
}
describe, ORDERS and validate from the previous section live in the same file, unexported. Notice what the module does not do: it never mutates the input array or its objects, it has no side effects when imported, and it has no dependencies. Every one of those makes the library safer to adopt. The JSDoc comments document the API for readers and editors, and also feed the type declarations below.
Tests follow the pattern from the previous lesson, with node:test in test/query.test.js. Test the documented behaviour, every error message and edge cases such as an empty array, limit: 0 and missing properties.
Types
Many users write TypeScript, and every user's editor reads type declarations for autocompletion. You can keep writing JavaScript and generate a .d.ts file from the JSDoc comments with the TypeScript compiler:
npx -p typescript tsc src/index.js --allowJs --declaration --emitDeclarationOnly --outDir types
This writes types/index.d.ts, which describes the exported query function's parameters and return type. The TypeScript course covers writing declarations by hand.
package.json
The manifest describes the package to npm and to Node.js:
{
"name": "@your-name/list-query",
"version": "1.0.0",
"description": "Filter, search, sort and limit arrays of objects without mutating them.",
"type": "module",
"exports": {
".": {
"types": "./types/index.d.ts",
"default": "./src/index.js"
}
},
"files": ["src", "types"],
"sideEffects": false,
"engines": { "node": ">=20" },
"scripts": {
"test": "node --test",
"build:types": "tsc src/index.js --allowJs --declaration --emitDeclarationOnly --outDir types",
"prepublishOnly": "npm test && npm run build:types"
},
"devDependencies": {
"typescript": "^5.0.0"
},
"keywords": ["filter", "sort", "search", "array"],
"license": "MIT"
}
The important fields:
exportsdefines the package's public entry points. Files not listed cannot be imported by users, so you are free to reorganise internals. Thetypescondition must come first so TypeScript finds it.fileslists what is published. Tests and notes stay out of the package.sideEffects: falsetells bundlers that importing the module does nothing by itself, so unused exports can be removed.enginesstates which Node.js versions you support.prepublishOnlyruns beforenpm publish, so you cannot release untested code or stale types.
Add a README.md with installation, one short example, every option with its default, and the errors it throws. Add a LICENSE file matching the license field.
Versioning
npm packages use semantic versioning: MAJOR.MINOR.PATCH.
| Change | Bump | Example |
|---|---|---|
| Bug fix, no API change | Patch | 1.0.0 to 1.0.1 |
| New option or function, nothing existing breaks | Minor | 1.0.1 to 1.1.0 |
| Anything that can break existing callers | Major | 1.1.0 to 2.0.0 |
Breaking changes include removing or renaming an option, changing a default, throwing in a case that used to work, changing the return shape and dropping support for a Node.js version. Keep a CHANGELOG.md describing each release in words. Before removing something, deprecate it in a minor release and document the replacement, so users can migrate before the major release.
npm version patch, npm version minor and npm version major update the version in package.json and, in a git repository, create a matching commit and tag.
Publishing
- Create an account on npmjs.com and run
npm login. - Check exactly what will be published:
npm pack --dry-run
- Publish. Scoped packages (names starting with
@your-name/) are private by default, so make the first release public explicitly:
npm publish --access public
- Try it from a fresh folder:
npm install @your-name/list-query, then import it from an ES module.
Published versions are permanent in practice: npm does not let you reuse a version number, so a mistake is fixed by publishing a new patch release.
Before publishing anything, ask whether the platform already does it. Many small npm packages exist only because a built-in did not yet exist when they were written. structuredClone, Object.groupBy, Array.prototype.toSorted and AbortSignal.timeout have all replaced popular helpers.
Summary
- Design from the call site: one clear function, data first, options in an object with sensible defaults.
- Validate at the public boundary and throw
TypeErrororRangeErrorwith messages that name the library, the expected value and the received value. - Do not mutate inputs, avoid side effects at import time and avoid unnecessary dependencies.
- Generate type declarations from JSDoc, and declare entry points with
exports. - Use semantic versioning, keep a changelog, and check
npm pack --dry-runbeforenpm publish.
You finished the course
That is the end of the JavaScript Complete Course. You started with variables and the console, and finished with the event loop, engine internals, cancellation and a published package. Along the way you built an accessible quiz app and a course dashboard that loads, filters, sorts, searches and caches real data.
Take the course quiz to check what has stuck and find the lessons worth revisiting. Then keep building: pick a small project you would actually use, write it in modules with tests, and reach for this course's lessons as a reference when you meet a pattern again.