Why @mongez/supportive-is?
Raw typeof and instanceof only get you so far — typeof null is "object", typeof [] is "object", and there's no built-in answer to "is this thing empty in the way I mean?". lodash.isString (and friends) work, but you either pay for the full lodash build or wire up a separate package per check and hope tree-shaking strips what you don't use. validator.js does email, URL, and friends, but it's a heavyweight string-validation library with a different scope.
@mongez/supportive-is is the smallest middle ground: one named export per predicate, sideEffects: false so bundlers actually drop the unused ones, zero runtime dependencies, and a Date/Map/Set/plain-object-aware isEmpty that collapses the ten lines you'd write by hand into one call. The package is shape predicates only — it tells you what kind of value you have, not how to transform it. Transformations live in @mongez/reinforcements.
import { isEmail, isEmpty, isPlainObject, isUrl } from "@mongez/supportive-is";
isEmail("user@example.com");
isEmpty({});
isEmpty(0);
isPlainObject(new Date());
isUrl("https://example.com");
Features
| Tree-shakable named exports | import { isString } brings in ~80 bytes; the unused predicates fall away. sideEffects: false is set. |
| Primitive checks | isString, isNumeric, isInt, isFloat, isPrimitive, isScalar — covers numeric-strings, signed numbers, and the bigint/symbol split. |
| Collection / shape checks | isObject, isPlainObject, isIterable, isEmpty plus Is.array — distinguishes {} from class instances, handles Map/Set/Date correctly. |
| Format predicates | isJson, isUrl, isEmail, isRegex, isValidId — convenience filters for the four formats every project needs. |
| Environment detection | isMobile.android()/.ios()/.iphone()/.ipad()/.ipod()/.windows()/.any(), isDesktop, isMac, isBrowser(name), plus vendor probes (isChrome/isFirefox/isSafari/isOpera/isIE/isEdge). |
| Object-kind checks | isPromise, isDate, isGenerator, isFormElement, isFormData. |
Smart isEmpty | One call covers null, undefined, "", [], {}, new Map(), new Set() — with 0, false, new Date(), and NaN deliberately reported as not empty. |
Legacy Is namespace | import Is from "@mongez/supportive-is"; Is.empty(x) still works — same functions, different shape, no tree-shaking. |
| SSR-safe imports | DOM-touching predicates read navigator/window/document at call time, never at module-eval. Importing the package on the server is always safe. |
| Zero runtime dependencies | One source file. No transitive packages. |
| TypeScript-first | isString is a real value is string type guard out of the box. |
Installation
npm install @mongez/supportive-is
yarn add @mongez/supportive-is
pnpm add @mongez/supportive-is
Quick start
import {
isString,
isEmpty,
isNumeric,
isUrl,
isEmail,
isPlainObject,
isPromise,
isMobile,
} from "@mongez/supportive-is";
isString("hello");
isEmpty({});
isEmpty(0);
isNumeric("12.5");
isUrl("https://example.com");
isEmail("user@example.com");
isPlainObject(new Date());
isPromise(fetch("/api"));
if (isMobile.any()) { }
That's the entire happy path. Everything below is depth on the same set of predicates.
Primitive checks
Six predicates for primitive and numeric types. All run in O(1) everywhere.
isString(value) | typeof value === "string" | TS narrows correctly. Excludes new String("x"). |
isNumeric(value) | Number or numeric string | Accepts "12", "-1.5", "+1", "1.5E-3". Rejects "", "1.", "12abc". |
isInt(value) | typeof value === "number" AND Number.isInteger(value) | Accepts signed ints, rejects 1.5, "2", NaN, Infinity. |
isFloat(value) | Finite number whose string form has a fractional part | Accepts 1.5, -0.1. Rejects 1, 1.0, "1.5". |
isPrimitive(value) | string / number / boolean / bigint | Excludes symbol and null/undefined. |
isScalar(value) | string / number / boolean / bigint / symbol | The "anything that isn't an object reference" check. |
import { isNumeric, isInt, isFloat, isPrimitive, isScalar } from "@mongez/supportive-is";
isNumeric(0);
isNumeric("12");
isNumeric("1e10");
isNumeric("1.");
isNumeric("hello");
isInt(0);
isInt(-1);
isInt(1.5);
isInt("2");
isFloat(1.5);
isFloat(-0.1);
isFloat(1);
isFloat(1.0);
isPrimitive(Symbol("x"));
isScalar(Symbol("x"));
isString is the only one that narrows in TypeScript out of the box. The others are typed as (value: any) => boolean because the parameter is any. Wrap them in your own typed guard (function isObjLike<T extends object>(v: unknown): v is T { return isPlainObject(v); }) when you need narrowing on union types.
Collection and shape checks
Five predicates that answer "what shape is this, and is there anything in it?".
isObject(value) | Truthy AND typeof value === "object". Includes arrays, dates, regexes, class instances. Excludes null and functions. |
isPlainObject(value) | {} / new Object() / Object.create(null) only — not arrays, dates, class instances. |
Is.array(value) | Alias for Array.isArray. |
isIterable(value) | Implements [Symbol.iterator] — arrays, strings, Set, Map, custom generators. |
isEmpty(value) | Smart emptiness — see Empty semantics. |
import { isObject, isPlainObject, isIterable, isEmpty, Is } from "@mongez/supportive-is";
isObject({});
isObject([]);
isObject(new Date());
isObject(null);
isPlainObject({ a: 1 });
isPlainObject(Object.create(null));
isPlainObject([]);
isPlainObject(new Date());
isPlainObject(new class {});
Is.array([1, 2, 3]);
Is.array(new Set());
Is.array({ length: 0 });
isIterable("hello");
isIterable("");
isIterable(new Map());
isIterable({});
isPlainObject is the right predicate for "should I merge into this, or replace it?" — class instances, Date, Map, Set, and RegExp all want to be replaced wholesale, and isPlainObject returns false for every one of them. See the deep-merge recipe.
Empty semantics
isEmpty is the most-used predicate in the package. It collapses the ten lines you'd write by hand into one:
null, undefined, "" | true | Listed in the empty set |
[], {} | true | Zero own keys / zero length |
new Map(), new Set() | true | .size === 0 |
0, true, false, "0", " " | false | Real values |
[0], { a: 1 }, 1, -1 | false | Has content |
new Date() | false | A constructed date is not empty |
NaN | false | Still a numeric value |
| Other iterables | .length === 0 | Generic check |
import { isEmpty } from "@mongez/supportive-is";
isEmpty(null);
isEmpty(undefined);
isEmpty("");
isEmpty([]);
isEmpty({});
isEmpty(new Map());
isEmpty(new Set());
isEmpty(0);
isEmpty(false);
isEmpty("0");
isEmpty(" ");
isEmpty([0]);
isEmpty({ a: 1 });
isEmpty(new Date());
isEmpty(NaN);
isEmpty does not trim whitespace. isEmpty(" ") returns false. If you mean "empty after trimming", call isEmpty(value?.trim?.() ?? value) at the call site, or use a dedicated string library.
Falsy-return note
isObject, isPlainObject, isRegex, isPromise, isDate, and isIterable use the truthiness shape Boolean(value) && … — they return real false for null/undefined. In if/!/ternary contexts they behave exactly as you'd expect. Don't compare them to false with ===:
import { isObject } from "@mongez/supportive-is";
if (isObject(null) === false) { }
if (!isObject(null)) { }
Format predicates
Five string-format predicates. None of these are security gates. They're convenience filters — for input you don't trust (auth flows, redirect targets, persisted emails), pair them with a real validator like zod or valibot, or do the actual operation in a try/catch.
isRegex(value) | RegExp instance or regex literal. |
isValidId(value) | Valid HTML id attribute — /^[A-Za-z]+[\w\-:.]*$/. Also aliased as Is.validHtmlId. |
isJson(value) | Valid JSON string starting with { or [. Rejects primitive JSON ("true", "123", '"hello"'). |
isUrl(value) | Constructable new URL(value) with http: / https: protocol and a non-empty dotted hostname. |
isEmail(value) | Standard RFC 5322 subset regex. |
import { isRegex, isValidId, isJson, isUrl, isEmail } from "@mongez/supportive-is";
isRegex(/x/);
isRegex(new RegExp("x"));
isRegex("/x/");
isValidId("base-id");
isValidId("has.dots");
isValidId("has:colon");
isValidId("1starts-with-digit");
isValidId("_starts-with-underscore");
isValidId(null);
isJson('{"name":"John"}');
isJson("[1, 2, 3]");
isJson("12");
isJson('"hello"');
isJson("{name:1}");
isUrl("https://example.com");
isUrl("http://example.com:8080/p?q");
isUrl("google.com");
isUrl("ftp://example.com");
isUrl("https://google.");
isUrl("https://google..com");
isEmail("user@example.com");
isEmail("a.b.c@example.co.uk");
isEmail("user+tag@example.com");
isEmail("a@b");
isEmail(["x@y.com"]);
isJson rejects primitive JSON by design. JSON.parse("123") succeeds but isJson("123") returns false because the first character isn't { or [. If you need to accept primitives, call JSON.parse inside a try/catch directly.
Object-kind checks
Five predicates for specific built-in object types. Use them when you need to branch on what kind of object you received — a Promise, a Date, a form node, etc.
isPromise(value) | value instanceof Promise — accepts subclasses too. |
isDate(value) | value instanceof Date. Invalid dates (new Date("nope")) still count. |
isGenerator(value) | Duck-typed: object + .next function + self-iterable. |
isFormElement(value) | value instanceof HTMLFormElement. SSR-safe — returns false when HTMLFormElement is undefined. Aliased as Is.form. |
isFormData(value) | value instanceof FormData. |
import { isPromise, isDate, isGenerator, isFormElement, isFormData } from "@mongez/supportive-is";
isPromise(Promise.resolve());
isPromise(fetch("/api"));
isPromise(async () => 1)();
isPromise({ then() {} });
isDate(new Date());
isDate(new Date("not real"));
isDate("2024-01-01");
isDate(Date.now());
function* count() { yield 1; }
isGenerator(count());
isGenerator(count);
isFormElement(document.createElement("form"));
isFormElement(document.createElement("div"));
isFormData(new FormData());
isDate returns true for invalid dates. new Date("not real") is still a Date instance — the timestamp is NaN, but the type is intact. If you need "is it a valid Date", combine with Number.isFinite(value.getTime()):
function isValidDate(v: unknown): v is Date {
return isDate(v) && !Number.isNaN((v as Date).getTime());
}
Environment checks
Predicates that read navigator, window, and document. They're safe to import on the server — none of them touch a global at module-eval. They throw on the server if you call them without a polyfill.
isMobile.android() / .ios() / .iphone() / .ipad() / .ipod() / .windows() / .any() | navigator.userAgent | Device-class branching. |
isDesktop() | navigator.userAgent | !isMobile.any(). |
isMac() | navigator.userAgent | Cmd-vs-Ctrl keyboard hints. |
isBrowser(name) | Vendor-specific globals | One entry point, takes "chrome" | "safari" | "firefox" | "opera" | "edge" | "ie". |
isChrome() / isFirefox() / isSafari() / isOpera() / isIE() / isEdge() | Vendor-specific globals | Direct vendor probes (faster than isBrowser). |
import { isMobile, isMac, isDesktop, isBrowser, isChrome } from "@mongez/supportive-is";
isMobile.any();
isMobile.iphone();
isDesktop();
isMac();
isChrome();
isBrowser("firefox");
isBrowser("Safari");
isMobile.any() reads navigator.userAgent every call, which is cheap but adds up in tight render loops — cache the result if you reference it more than a couple of times per render.
SSR safety
import { isMobile, isMac } from "@mongez/supportive-is";
export function DeviceClass() {
return isMobile.any();
}
Gate the call when the same code runs in both environments:
const onClient = typeof window !== "undefined";
const device = onClient ? (isMobile.any() ? "mobile" : "desktop") : "unknown";
For server-side UA-aware rendering in Next.js / Remix / TanStack Start, parse request.headers.get("user-agent") yourself with a dedicated UA parser — these predicates are written for the browser, not for the request-handler path.
Recipes
Validate a form input is a valid email
Reach for this when a form has a required email field and you want a single function that returns a Record<field, message> of errors. Combine isEmpty (for the required check) with isEmail (for format) — and use isUrl for optional URL fields the same way.
import { isEmail, isEmpty, isUrl } from "@mongez/supportive-is";
type ContactForm = {
name?: string;
email?: string;
website?: string;
};
function validate(form: ContactForm) {
const errors: Partial<Record<keyof ContactForm, string>> = {};
if (isEmpty(form.name)) errors.name = "Name is required";
if (isEmpty(form.email)) {
errors.email = "Email is required";
} else if (!isEmail(form.email!)) {
errors.email = "Email is invalid";
}
if (!isEmpty(form.website) && !isUrl(form.website!)) {
errors.website = "Website must be a full http(s) URL";
}
return errors;
}
validate({ name: "", email: "u@x.com" });
validate({ name: "Hasan", email: "not-an-email" });
Branch logic based on environment
Reach for this when the same component needs different padding, different keyboard hints, or different navigation chrome on mobile vs desktop.
import { isMac, isMobile } from "@mongez/supportive-is";
const onMobile = isMobile.any();
const modKey = isMac() ? "Cmd" : "Ctrl";
export function CommandBar() {
return (
<div style={{ padding: onMobile ? 12 : 24 }}>
{onMobile ? <MobileNav /> : <DesktopNav />}
<kbd>{modKey} + K</kbd> to open the command palette
</div>
);
}
If your bundle runs in SSR, gate the call sites with typeof window !== "undefined" or move the read into a useEffect — isMobile.any() throws when navigator isn't defined.
A deep merge that respects class instances
Reach for this when you want to merge nested config objects but not merge into Date, Map, RegExp, or class instances — those should be replaced wholesale. isPlainObject returns false for all of them.
import { isPlainObject } from "@mongez/supportive-is";
function deepMerge<T extends object>(target: T, source: Partial<T>): T {
for (const key of Object.keys(source) as (keyof T)[]) {
const next = source[key];
const current = target[key];
if (isPlainObject(next) && isPlainObject(current)) {
target[key] = deepMerge(current as object, next as object) as T[keyof T];
} else {
target[key] = next as T[keyof T];
}
}
return target;
}
deepMerge(
{ user: { name: "A", joined: new Date("2024-01-01") } },
{ user: { joined: new Date("2025-01-01") } },
);
Filter empty fields out of an outbound payload
Reach for this before sending a form payload to an API: drop the keys with no real value (so the server's PATCH semantics only touch what the user actually filled in), but keep 0 and false, which isEmpty correctly preserves.
import { isEmpty } from "@mongez/supportive-is";
function pickNotEmpty<T extends Record<string, unknown>>(input: T): Partial<T> {
const out: Partial<T> = {};
for (const k of Object.keys(input) as (keyof T)[]) {
if (!isEmpty(input[k])) out[k] = input[k];
}
return out;
}
pickNotEmpty({
name: "Hasan",
bio: "",
age: 0,
preferences: {},
newsletter: false,
website: null,
});
Safely coerce user input into a URL object
Reach for this when a user pastes a link into your app and you want to parse it before rendering. new URL(...) throws on bad input; isUrl gives you a fast pre-check, and the try/catch is the belt-and-suspenders pair.
import { isUrl } from "@mongez/supportive-is";
function safeUrl(input: string): URL | null {
if (!isUrl(input)) return null;
try {
return new URL(input);
} catch {
return null;
}
}
const u = safeUrl("https://example.com/search?q=mongez");
u?.searchParams.get("q");
safeUrl("not a url");
safeUrl("ftp://x.com");
For untrusted input, do the actual fetch / navigation inside a try/catch. isUrl is a convenience filter, not a security gate. If you're rendering an <a href> on the client, also sanitize the hostname (block javascript:, data:, file:, etc — isUrl already rejects those, but defense-in-depth never hurts).
Build a polymorphic "string-or-regex" search API
Reach for this when you want a single function that takes either a literal string or a pre-built regex — pretty much every "find" / "match" / "highlight" helper you'll ever write.
import { isRegex } from "@mongez/supportive-is";
function findAll(haystack: string, needle: string | RegExp): string[] {
const re = isRegex(needle) ? needle : new RegExp(needle, "g");
return Array.from(haystack.matchAll(re), (m) => m[0]);
}
findAll("alice bob alice", "alice");
findAll("alice bob alice", /\balice\b/g);
findAll("v1.2.3 and v4.5.6", /v\d+/g);
Narrow a union type in TypeScript
Reach for this when you have a string | number (or similar) and want both branches typed correctly. isString is a built-in value is string guard. For the other predicates the parameter is typed as any, so you wrap them in your own type guard.
import { isString, isPlainObject } from "@mongez/supportive-is";
function format(v: string | number): string {
if (isString(v)) {
return v.toUpperCase();
}
return v.toFixed(2);
}
function isRecord(v: unknown): v is Record<string, unknown> {
return isPlainObject(v);
}
function readConfig(input: unknown) {
if (!isRecord(input)) throw new Error("Expected a plain object");
return input.port ?? 3000;
}
Migrating from v1
The v1 surface was a single default export:
import Is from "@mongez/supportive-is";
Is.string("x");
Is.empty([]);
v2 keeps that import working — Is is still exported as the default — but each predicate is also a named export, so bundlers can drop the ones you don't use:
import { isString, isEmpty } from "@mongez/supportive-is";
isString("x");
isEmpty([]);
The exported Is object collects the canonical predicates from this package; methods removed in v2 (Is.cssSelector, Is.htmlTag, Is.callable, …) stay removed.
Named imports are roughly 80 bytes per predicate after minify+gzip; the whole Is namespace is ~3 KB. If you only use one or two predicates, named imports cut the cost by ~95%.
TypeScript
The package ships its types from source — no separate @types/ package needed. Every export is typed, and you can re-import the legacy namespace as a type when you need to:
import { isEmpty, isPlainObject, Is } from "@mongez/supportive-is";
import type { default as IsType } from "@mongez/supportive-is";
const ns: typeof Is = Is;
type Namespace = typeof IsType;
Most predicates are typed as (value: any) => boolean because they accept anything you can hand them. The exception is isString, which TypeScript narrows to value is string automatically:
import { isString } from "@mongez/supportive-is";
function format(v: string | number): string {
if (isString(v)) return v.toUpperCase();
return v.toFixed(2);
}
For the others, wrap them in your own typed guard when you need narrowing on a union:
import { isPlainObject, isPromise, isDate } from "@mongez/supportive-is";
function isRecord(v: unknown): v is Record<string, unknown> {
return isPlainObject(v);
}
function isThenable<T>(v: unknown): v is Promise<T> {
return isPromise(v);
}
function isDateInstance(v: unknown): v is Date {
return isDate(v);
}
This wrapping pattern is one line, and you only need to do it once per predicate per project. The wrapper compiles to a single call — there's no runtime cost.
Quick rules
- One predicate = one named import. Don't import the default
Is namespace just for tree-shaking convenience — bundlers can't statically prove which keys you'll touch on a namespace object.
- No runtime dependencies. Every predicate is one short function. If you need date math, regex composition, or schema validation, use the right tool — not this package.
- DOM predicates run lazily. Importing
isMobile on the server is safe; calling isMobile.android() on the server throws because navigator isn't defined.
- Predicates are not validators.
isUrl / isEmail / isJson are convenience filters. For trust decisions (open the link / send to address / interpret as JSON), do the actual operation in a try/catch or pair with zod/valibot.
isEmpty keeps 0, false, and new Date(). Use value == null || value === "" directly if you want a stricter "nullish-or-blank" check.
Related packages
@mongez/reinforcements | The transformations you reach for after the predicate passes — object/string/array helpers (get, set, clone, slugify, groupBy, …). |
@mongez/atom | Framework-agnostic reactive state — pairs with isPlainObject for safe atom payloads. |
@mongez/events | Tiny event bus. |
@mongez/dotenv | Typed .env loader — pairs naturally with isEmpty(env("X")) checks at boot. |
For deeper validation (schema, transformation, custom error shapes), use zod or valibot directly — those are full validation libraries and a different scope than the predicates here.
Further reading
CHANGELOG.md — release notes and the v1 → v2 migration list.
llms-full.txt — exhaustive single-file API surface for tool-assisted development.
skills/ — per-topic deep-dives (primitives, collections, formats, misc, environment, recipes).
License
MIT — see LICENSE.