npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ahmetilhn/handy-utils

v4.2.0

Published

Handy utils offers developers a powerful and easy-to-use toolset. With its clean, modular and scalable code structure, it accelerates development processes and reduces code complexity. With a wide range of functions, it offers ready-made methods for solvi

Readme

handy-utils

A small, dependency-free toolkit of the checks and helpers most projects end up rewriting: type guards that actually narrow, a structural equality function that does not lie about Maps and URLs, a clone that survives circular references, and a handful of everyday utilities.

  • 22 functions, zero dependencies.
  • Written in TypeScript. Every guard is a real type predicate.
  • Correct on hard inputs. Circular references, NaN, -0, Map, Set, Date, RegExp, typed arrays and class instances are all handled.
  • Isomorphic. Nothing touches window unless you call a browser helper.
  • 100% test coverage, enforced in CI across 267 tests.

Table of contents


Requirements

Node.js 24 or newer. In the browser, any engine supporting ES2022 — every evergreen release since 2022.

Installation

npm install @ahmetilhn/handy-utils
yarn add @ahmetilhn/handy-utils

Quick start

Everything is a named export, so bundlers drop what you do not use.

import { isDeepEqual, deepClone, isDefined, sleep } from "@ahmetilhn/handy-utils";

isDeepEqual({ a: [1, 2] }, { a: [1, 2] }); // true
deepClone(new Map([["k", { n: 1 }]])); // a real Map, deeply copied

const values = [1, null, 2];
const defined = values.filter(isDefined); // number[] — narrowed

await sleep(200);

CommonJS works the same way:

const { isDeepEqual } = require("@ahmetilhn/handy-utils");

API reference

Comparison and cloning

isDeepEqual

Structural equality with Object.is semantics for primitives. Safe against circular references.

isDeepEqual(valOne: unknown, valTwo: unknown): boolean
// Primitives use Object.is: NaN matches itself, 0 and -0 stay distinct
isDeepEqual(10, 10); // true
isDeepEqual(NaN, NaN); // true
isDeepEqual(0, -0); // false
isDeepEqual(1, "1"); // false
isDeepEqual(null, undefined); // false

// Objects and arrays compare by content; key order is irrelevant
isDeepEqual({ name: "john" }, { name: "john" }); // true
isDeepEqual({ a: 1, b: 2 }, { b: 2, a: 1 }); // true
isDeepEqual({ a: 1 }, { a: 1, b: 2 }); // false
isDeepEqual([1, [2]], [1, [2]]); // true
isDeepEqual([1, 2], [2, 1]); // false — order matters in arrays

// Built-ins compare by value
isDeepEqual(new Date(5), new Date(5)); // true
isDeepEqual(/foo/g, /foo/i); // false — flags differ
isDeepEqual(new Map([["a", 1]]), new Map([["a", 1]])); // true
isDeepEqual(new Set([1, 2]), new Set([2, 1])); // true — order irrelevant
isDeepEqual(new Error("boom"), new Error("boom")); // true
isDeepEqual(new URL("https://a.com"), new URL("https://b.com")); // false
isDeepEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2])); // true

// Class instances need a matching prototype as well as matching fields
class Point {
  constructor(public x: number) {}
}
isDeepEqual(new Point(1), new Point(1)); // true
isDeepEqual(new Point(1), { x: 1 }); // false

// Functions are equal only by reference
const fn = () => 10;
isDeepEqual(fn, fn); // true
isDeepEqual(() => 10, () => 10); // false

// Circular input is handled
const a: any = { name: "a" };
a.self = a;
const b: any = { name: "a" };
b.self = b;
isDeepEqual(a, b); // true

Why functions compare by reference. Two functions with identical source can capture different closure state, so they are not interchangeable:

const make = (k: number) => (x: number) => x + k;
make(1)(0); // 1
make(99)(0); // 99
// Identical source, different behaviour — comparing toString() would call
// these equal. lodash and Node's assert.deepStrictEqual agree: not equal.

Values with no observable structureWeakMap, WeakSet, Promise, generators, DOM nodes — can only be compared by reference. See How values are compared for the full table.


deepClone

Recursive structural clone that disconnects every data binding. Safe against circular and shared references, and preserves prototypes.

deepClone<T>(val: T): T
const original = { name: "test", tags: ["a"] };
const copy = deepClone(original);

copy !== original; // a new reference
copy.tags !== original.tags; // nested values copied too
isDeepEqual(copy, original); // structurally identical

// Built-ins keep their type
deepClone(new Map([["a", 1]])) instanceof Map; // true
deepClone(new Set([1])) instanceof Set; // true
deepClone(/foo/g) instanceof RegExp; // true — source, flags and lastIndex kept
deepClone(new Date()) instanceof Date; // true
deepClone(new Uint8Array([1, 2])) instanceof Uint8Array; // true
deepClone(new Error("boom")) instanceof Error; // true — name, stack, cause kept

// Class instances keep their prototype and therefore their methods
class Person {
  constructor(public name: string) {}
  greet() {
    return `hi ${this.name}`;
  }
}
deepClone(new Person("Ada")).greet(); // "hi Ada"

// Object.create(null) stays prototype-less
Object.getPrototypeOf(deepClone(Object.create(null))); // null

// Sparse arrays keep their length and their holes
deepClone([, 1, , 2]).length; // 4

// Enumerable symbol keys are copied
const id = Symbol("id");
deepClone({ [id]: 1 })[id]; // 1

Circular and shared references:

const node: any = { name: "root" };
node.self = node;
const copy = deepClone(node);
copy.self === copy; // true — the cycle is rebuilt, not followed forever

// Sharing is preserved within one clone
const shared = { n: 1 };
const cloned = deepClone({ a: shared, b: shared });
cloned.a === cloned.b; // true

Shared by reference on purpose: functions, WeakMap, WeakSet and Promise. Copying them is not meaningful — a cloned function would break identity comparisons, and a weak collection's contents are not enumerable.


Type guards

Every guard below is a TypeScript type predicate, so it narrows inside if blocks and works as a .filter() argument.

isDefined

Neither null nor undefined. Narrows to NonNullable<T>.

isDefined<T>(val: T): val is NonNullable<T>
isDefined("value"); // true
isDefined(0); // true — falsy, but defined
isDefined(""); // true
isDefined(false); // true
isDefined(null); // false
isDefined(undefined); // false

// Narrowing
declare const maybe: string | null | undefined;
if (isDefined(maybe)) {
  maybe.toUpperCase(); // string
}

// As a filter predicate
const values: Array<number | null> = [1, null, 2];
const defined: number[] = values.filter(isDefined); // [1, 2]

isNull

isNull(val: unknown): val is null
isNull(null); // true
isNull(undefined); // false
isNull(0); // false

isUndefined

isUndefined(val: unknown): val is undefined
isUndefined(undefined); // true
isUndefined(null); // false

isNumber

A finite number. NaN and Infinity are numbers by typeof but not values you can compute with, so both are rejected.

isNumber(val: unknown): val is number
isNumber(1); // true
isNumber(-5.5); // true
isNumber(0); // true

isNumber(NaN); // false
isNumber(Infinity); // false
isNumber("1"); // false
isNumber(null); // false
isNumber(new Number(1)); // false — boxed, typeof is "object"

isBoolean

isBoolean(val: unknown): val is boolean
isBoolean(true); // true
isBoolean(false); // true
isBoolean(0); // false
isBoolean(new Boolean(true)); // false — boxed, not a primitive

isArray

isArray(val: unknown): val is Array<any>
isArray([]); // true
isArray([1, 2]); // true
isArray("ab"); // false
isArray(new Uint8Array()); // false — a typed array is not an Array
isArray({ length: 0 }); // false

isObject

Any non-null value of typeof "object". Arrays and dates count; functions do not. For "is this a {}-style object", use isPlainObject.

isObject(val: unknown): val is object
isObject({}); // true
isObject([]); // true
isObject(new Date()); // true
isObject(null); // false
isObject(() => {}); // false

isPlainObject

An object created by an object literal, new Object() or Object.create(null) — its prototype is Object.prototype or null. Built-ins and class instances are objects but not plain: their state lives in internal slots or behind prototype accessors, not in own enumerable keys.

isPlainObject(val: unknown): val is Record<string, unknown>
isPlainObject({}); // true
isPlainObject({ name: "john" }); // true
isPlainObject(Object.create(null)); // true

isPlainObject([]); // false
isPlainObject(new Date()); // false
isPlainObject(new Map()); // false
isPlainObject(new URL("https://a.com")); // false
isPlainObject(new (class Point {})()); // false
isPlainObject(null); // false

Cross-realm objects (from an iframe or a vm context) are recognised too.

isDate

A Date instance. Note this is a type check, not a validity check — an invalid date is still a Date.

isDate(val: unknown): val is Date
isDate(new Date()); // true
isDate(new Date("nope")); // true — still a Date object
isDate(Date.now()); // false — that is a number
isDate("2023-01-01"); // false

To also require validity: isDate(v) && !Number.isNaN(v.getTime()).

isFunction

Any callable, including arrow, async, generator and async-generator functions, and class constructors.

isFunction(val: unknown): val is Function
isFunction(() => {}); // true
isFunction(async () => {}); // true
isFunction(function* () {}); // true
isFunction(class Foo {}); // true
isFunction(Math.max); // true

isFunction({}); // false
isFunction(null); // false

hasPlainObjectRecord

Whether a plain object carries any own enumerable entry. Throws for anything that is not a plain object.

hasPlainObjectRecord(val: unknown): boolean
hasPlainObjectRecord({}); // false
hasPlainObjectRecord({ a: 1 }); // true
hasPlainObjectRecord({ [Symbol("id")]: 1 }); // true — symbol keys count

hasPlainObjectRecord(new Map()); // throws Error
hasPlainObjectRecord(null); // throws Error

Environment

isClient / isServer

Whether a window global exists. Safe to call anywhere — neither touches window unless it is present, so both are SSR-safe.

isClient(): boolean
isServer(): boolean
// In a browser
isClient(); // true
isServer(); // false

// In Node
isClient(); // false
isServer(); // true
if (isClient()) {
  localStorage.setItem("visited", "1");
}

Device

Both detectors accept an optional user agent. Pass one to test a string directly — useful on the server, where request headers are the only source. Omit it to read navigator.userAgent, which throws outside the browser.

isAndroid

isAndroid(userAgent?: string): boolean
isAndroid("Mozilla/5.0 (Linux; Android 14; Pixel 8)"); // true
isAndroid("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)"); // false
isAndroid(""); // false — an empty string is still a supplied user agent

isAndroid(); // reads navigator; throws on the server

isIos

Also recognises iPadOS 13 and newer, which reports itself as "Macintosh" and is otherwise indistinguishable from a desktop Mac. The tell is touch support, so this only applies when reading the live navigator; a supplied string is judged on its own.

isIos(userAgent?: string): boolean
isIos("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)"); // true
isIos("Mozilla/5.0 (iPad; CPU OS 17_0)"); // true
isIos("Mozilla/5.0 (Linux; Android 14)"); // false
isIos(""); // false

isIos(); // reads navigator, including the iPadOS check

Server-side usage from a request header:

const ua = request.headers["user-agent"] ?? "";
const isMobile = isIos(ua) || isAndroid(ua);

Utilities

debounce

Delay a function until the calls stop coming. Every call inside the window restarts it, so a burst collapses into a single invocation with the arguments of the last call. The receiver and the arguments are passed through untouched.

debounce<T extends (...args: any[]) => any>(
  fn: T,
  wait: number,
  options?: {
    leading?: boolean;   // invoke on the leading edge — default false
    trailing?: boolean;  // invoke on the trailing edge — default true
    maxWait?: number;    // never defer longer than this
  }
): Debounced<T>;

type Debounced<T> = {
  (...args: Parameters<T>): ReturnType<T> | undefined;
  cancel: () => void;                      // drop the pending call
  flush: () => ReturnType<T> | undefined;  // run the pending call now
  pending: () => boolean;
};
const search = debounce((term: string) => fetchResults(term), 300);

search("h");
search("ha");
search("han"); // only this one runs, 300ms after the last keystroke
// Leading edge: react to the first event, ignore the rest of the burst
const onScrollStart = debounce(track, 200, { leading: true, trailing: false });

// maxWait: a stream of events that never pauses still gets served every 500ms
const onResize = debounce(relayout, 100, { maxWait: 500 });

onResize.pending(); // true while a call is waiting
onResize.flush(); // run it now instead of waiting
onResize.cancel(); // or throw it away — e.g. on unmount

Notes:

  • The return value is the result of the previous invocation; a deferred call has not produced one yet. For a result you can await, wrap the call in a promise yourself.
  • cancel resets the window completely, so the next call counts as the first.
  • flush returns the last result untouched when nothing is pending.
  • With leading: true, a lone call fires once — there is nothing left to replay on the trailing edge.
  • maxWait shorter than wait is clamped to wait; a non-positive wait defers to the next timer tick.
  • Timing reads Date.now(), so a clock jump starts a fresh window instead of wedging the timer.

normalize

Express a value as a percentage of a maximum. Capped at 100 and rounded to two decimals.

normalize(value: number, max: number): number
normalize(50, 200); // 25
normalize(1, 3); // 33.33
normalize(2, 3); // 66.67
normalize(100, 100); // 100
normalize(150, 100); // 100 — capped
normalize(0, 100); // 0

Throws on invalid input:

normalize(5, 0); // RangeError — max must be greater than 0
normalize(5, -10); // RangeError
normalize(-1, 100); // RangeError — value must be non-negative
normalize(NaN, 100); // Error — Max or value must be number
normalize(null as any, 100); // Error — non-numbers are rejected

sleep

Pause for a number of milliseconds.

sleep(time: number): Promise<void>
const pollEverySecond = async () => {
  while (running) {
    await check();
    await sleep(1000);
  }
};

watcher

Wrap an object in a proxy that reports every change. Assignments and deletions both notify; a redundant assignment does not. The proxy writes through, so the original object stays in sync.

watcher<T extends object>(target: T, onChange: WatcherCallback<T>): T

type WatcherCallback<T extends object> = (
  key: keyof T,
  value: T[keyof T] | undefined,      // undefined when the key was deleted
  previous: T[keyof T] | undefined
) => void;
const state = watcher({ count: 0, name: "Ada" }, (key, value, previous) => {
  console.log(`${String(key)}: ${previous} → ${value}`);
});

state.count = 1; // "count: 0 → 1"
state.count = 1; // nothing — the value did not change
state.name = "Grace"; // "name: Ada → Grace"
delete state.count; // "count: 1 → undefined"

Notes:

  • Equality uses Object.is, so assigning NaN over NaN is not a change.
  • Assigning to a key that does not exist yet always reports, even if the value is undefined.
  • Setters on the target still run; the watcher does not bypass them.
  • Arrays work, but a single push reports both the new index and length.

withRetry

Run an async function, retrying with exponential backoff while it fails with a given error type. Returns null once the attempts are exhausted. Any other error class is rethrown immediately, without retrying.

withRetry<T>(props: {
  fn: () => Promise<T>;
  retries: number;   // total attempts, not extra attempts
  delay: number;     // base delay in ms; waits delay * 2 ** attemptIndex
  exception: new (...args: any[]) => Error;
}): Promise<T | null>
class NetworkError extends Error {}

const data = await withRetry({
  fn: () => fetchFromApi(),
  retries: 3,
  delay: 100, // waits 100ms after the first failure, then 200ms
  exception: NetworkError,
});

if (data === null) {
  // all three attempts threw a NetworkError
}
// A different error class is not retried
await withRetry({
  fn: async () => {
    throw new TypeError("bad input");
  },
  retries: 3,
  delay: 100,
  exception: NetworkError,
}); // rejects with the TypeError immediately

Pass exception: Error to retry every error. Note that retries: 0 returns null without ever calling fn.


How values are compared

isDeepEqual and deepClone classify values the same way. Knowing the table explains both.

| Input | isDeepEqual | deepClone | | ------------------------------------------- | ------------------------------------ | ------------------------------- | | Primitives | Object.isNaN matches, 0 ≠ -0 | Returned as-is | | Plain objects, arrays | By content, key order irrelevant | Deep copy, holes preserved | | Date | By timestamp | New Date | | RegExp | By source and flags | New RegExp, lastIndex kept | | Map, Set | By content, insertion order irrelevant | New Map / Set, entries cloned | | Error | By name and message | Same class, stack and cause kept | | Boxed Number / String / Boolean | By primitive value | Deep copy | | ArrayBuffer, DataView, typed arrays | Byte by byte | New buffer, bytes copied | | URL, URLSearchParams | By string form | Deep copy | | Class instances | Same prototype and same fields | Same prototype, fields copied | | Functions | Reference only | Shared by reference | | WeakMap, WeakSet, Promise, generators, DOM nodes | Reference only | Shared by reference | | Circular references | Handled | Handled |

Only own enumerable properties participate, including symbol keys. Inherited properties are ignored.


TypeScript

Types ship with the package; nothing extra to install.

import {
  isDefined,
  watcher,
  type WatcherCallback,
} from "@ahmetilhn/handy-utils";

Every is* function is a type predicate, so narrowing works in conditionals, ternaries and .filter():

declare const input: unknown;

if (isPlainObject(input)) {
  input.anyKey; // Record<string, unknown>
}

const cleaned = ["a", null, "b"].filter(isDefined); // string[]

deepClone preserves the input type:

const copy = deepClone({ list: [1, 2], when: new Date() });
// { list: number[]; when: Date }

Breaking changes in 4.0.0

isDeepEqual, deepClone, isPlainObject and isFunction returned wrong answers for several common inputs. Fixing them changes observable behaviour.

| Case | 3.x | 4.0.0 | | -------------------------------------------------------------- | -------------------------------- | ----------------------------------------------------------- | | isPlainObject(new Map()), new URL(), class instances | true | false — only literals and Object.create(null) are plain | | isDeepEqual on two Map/Set/RegExp/Error/URL values | true regardless of content | Compared by value | | isDeepEqual(NaN, NaN) | false | true | | isDeepEqual(0, -0) | true | false | | isDeepEqual on two functions with equal source | true (compared toString()) | false — only the same reference is equal | | isDeepEqual on circular input | RangeError | Handled | | deepClone(new Map()), RegExp, class instance | {} — type and prototype lost | Correct type and prototype | | deepClone on circular input | RangeError | Handled | | deepClone([, 1, , 2]) | [1, 2] — holes compacted | Length and holes preserved | | isFunction(async () => {}), generators | false | true | | watcher: assigning undefined to a new key | Key silently never created | Key created, change reported | | watcher: delete proxy.key | Deleted without notifying | Reported as a change with undefined | | isNumber(Infinity) | true, while NaN was false | false — finite numbers only | | normalize(null, max) | null coerced to 0 and accepted | Throws, as the message always promised | | isAndroid("") / isIos("") | Threw "only works on client" | false — an empty UA is still a UA | | hasPlainObjectRecord({ [Symbol()]: 1 }) | false — symbols invisible | true | | isDefined narrowing | Narrowed nothing | Narrows to NonNullable<T> | | sleep(ms) return type | Promise<unknown> | Promise<void> |

isDeepEqual and deepClone also gained support for typed arrays, ArrayBuffer, DataView, boxed primitives and enumerable symbol keys. isIos() gained iPadOS 13+ detection. The minimum Node.js version is now 24.

The function comparison is the change most likely to affect callers: two functions with identical source can capture different closure state, so they are not interchangeable. Compare functions by reference, as lodash and Node's assert.deepStrictEqual do.


Contributing

npm install
npm run verify   # typecheck, test with coverage, build

267 tests, enforced at 100% for statements, branches, functions and lines.

Releasing

Releases are automatic. Bump version in package.json and merge to master:

npm version patch   # or minor / major
git push origin master

The publish workflow then type-checks, tests, builds, smoke-tests the packed tarball in a clean project, publishes to npm with provenance, and pushes a v<version> tag.

A commit that does not change the version is not an error — the workflow sees the version already on npm and skips the release.

License

MIT © Ahmet ilhan