@rxova/ts-utils
v0.3.0
Published
Small, dependency-free runtime helpers shared across the rxova packages.
Readme
The helpers every package ends up writing, written once and tested hard: type guards that never throw on a hostile proxy, the message to show for anything thrown, development warnings a bundler can drop, a media query hook that does not tear. Browser and Node alike, no side effects, built to es2020, so a consumer's bundler inlines the few it uses and ships nothing else.
import { errorMessage, isRecord } from "@rxova/ts-utils";
try {
JSON.parse(input);
} catch (error) {
report(errorMessage(error)); // a string, whatever was thrown
}What you get
| | |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| 🔍 Guards | isObjectLike, isRecord, isPlainObject, isFiniteNumber, isError, …: the checks, named once. |
| 🛡️ Safe reads | Property reads, in, Object.keys and instanceof that survive throwing getters and proxy traps. |
| 🌱 Environment | /env: isDevelopment for output, isNonProduction for behaviour. canUseDOM and prefersReducedMotion stay in core. |
| ⚠️ Dev warnings | /env: createDevWarner, a prefix, stable codes linked to docs, warn-once. |
| 🧮 Values | shallowEqual, deepFreeze, clamp, sum, chunk, mapValues, escapeHtml, utf8ByteLength, randomHex. |
| 🎲 Random | randomBelow and shuffle, unbiased, plus seededRandom and hashString for orders that must come back. |
| 🧾 JSON | stableStringify and jsonEqual: sorted keys, so a fingerprint survives a different writer. |
| ⚛️ React | /react: an isomorphic layout effect, latest-value and merged refs, a media query hook, per-instance warnings. |
| 📦 Zero cost | No dependencies, sideEffects: false, ESM: what you do not import is not in the bundle. |
Install
In an rxova repository, add it once, to the root package.json, as a dev dependency:
pnpm add -D -w @rxova/ts-utils@rxova/ts-utils is declared only in the root package.json, never in a published package's
dependencies. The package's build inlines the helpers it imports: reactBuildConfig from
@rxova/repo-config/tsdown sets deps.onlyBundle: ["@rxova/ts-utils"], which bundles ts-utils and
fails the build on any other dependency. The published package stays dependency-free and inside its
size budget. A package built with another tsdown preset sets the same:
// packages/<name>/tsdown.config.ts
import { defineConfig } from "tsdown";
import { dualBuildConfig } from "@rxova/repo-config/tsdown";
export default defineConfig(dualBuildConfig({ deps: { onlyBundle: ["@rxova/ts-utils"] } }));react (18 or newer) is an optional peer dependency; only @rxova/ts-utils/react imports it.
Quick start
import { isRecord, readString } from "@rxova/ts-utils";
import { createDevWarner } from "@rxova/ts-utils/env";
const warner = createDevWarner({ prefix: "my-package", docsUrl: "https://example.com/warnings" });
export const parseOptions = (value: unknown): string | undefined => {
if (!isRecord(value)) {
if (process.env.NODE_ENV !== "production") {
warner.warnOnce("options", "options must be an object", { code: "MP1001" });
}
return undefined;
}
return readString(value, "label");
};import { useMediaQuery, useMergedRefs } from "@rxova/ts-utils/react";
import { useRef, type Ref } from "react";
export const Panel = ({ ref }: { ref?: Ref<HTMLDivElement> }) => {
const own = useRef<HTMLDivElement>(null);
const reduced = useMediaQuery("(prefers-reduced-motion: reduce)");
return <div ref={useMergedRefs(own, ref)} data-animate={!reduced} />;
};@rxova/ts-utils
Guards
| Export | What it answers |
| ----------------------------- | ------------------------------------------------------------------------------- |
| isObjectLike(value) | Any non-null object, arrays and class instances included. |
| isRecord(value) | A non-null, non-array object. Class instances pass. |
| isPlainObject(value) | An object literal, JSON.parse result or Object.create(null) bag. |
| isFiniteNumber(value) | A number that is not NaN or an infinity, narrowed to number. |
| isNonNegativeInteger(value) | A whole number from 0 to Number.MAX_SAFE_INTEGER: a count or an index. |
| isError(value) | A real Error, from this realm or another. { message } alone does not count. |
| isErrorLike(value) | Any object whose message is a string. A throwing getter makes it false. |
Safe reads — never let a getter, a proxy trap or Symbol.hasInstance throw
| Export | Returns |
| ---------------------------- | --------------------------------------------------------------------- |
| tryRead(value, key) | { ok: true, value }, or { ok: false } when the read threw. |
| readProperty(value, key) | The property, or undefined when reading it throws. |
| readString(value, key) | The property when it is a string, else undefined. |
| hasProperty(value, key) | key in value, false when a proxy refuses. |
| safeKeys(value) | Object.keys, [] when a proxy refuses. |
| isInstanceOf(value, Class) | instanceof, false when it throws. |
| objectTag(value) | "[object Map]" and friends, across realms; undefined if refused. |
| arrayItems(value) | A copy of an array, else undefined. |
| readArray(value, readItem) | Each item through readItem; undefined when the value is no array. |
Errors and DOM
| Export | What it does |
| ------------------------ | ---------------------------------------------------------------------------------------------------- |
| errorMessage(value) | The text to show for anything thrown: an Error's message, a string, else String(). Never throws. |
| canUseDOM() | Whether both window and document exist. |
| prefersReducedMotion() | The reduced-motion media query, now. false on the server or without matchMedia. |
Values
| Export | What it does |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| shallowEqual(a, b) | Object.is for each own key. { a: undefined } is not equal to { b: undefined }. |
| deepFreeze(value) | Freezes the value and everything reachable from it, Map and Set entries included. Skips typed arrays and DataViews, survives cycles. |
| clamp(value, min, max) | value kept within [min, max]. NaN stays NaN; a reversed range throws a RangeError. |
| sum(values) | The numbers of any iterable added up from 0; [] is 0 and a NaN shows as NaN. |
| chunk(items, size) | items in runs of size, the last one shorter; a size that is not a positive integer throws a RangeError. |
| mapValues(record, transform) | A new object with each own string key's value passed through transform(value, key). |
| stableStringify(value) | JSON.stringify with every object's keys sorted, for comparing, hashing or storing a document. |
| jsonEqual(a, b) | Whether two values are the same JSON document, whatever order their keys were written in. |
| escapeHtml(value) | & < > " ' as entities, for HTML or XML text and quoted attributes. Not a sanitizer. |
| utf8ByteLength(text) | The UTF-8 size of text, as TextEncoder counts it, without encoding a copy. |
| randomHex(bytes, { onFallback? }) | 2 * bytes lowercase hex digits from crypto.getRandomValues; Math.random fallback, reported once through onFallback. |
| randomBelow(bound, { onFallback? }) | A whole number in [0, bound), drawn without bias by rejection sampling; same Math.random fallback. |
| shuffle(items, below?) | A new array in random order, by Fisher-Yates; below defaults to randomBelow. |
| seededRandom(seed) | A mulberry32 generator: the same [0, 1) sequence every time for the same seed. Not unpredictable. |
| hashString(text) | FNV-1a as an unsigned 32-bit number, for a seed or a bucket. Not cryptographic. |
Types: ReadResult, ReadArrayOptions, RandomHexOptions, RandomBelowOptions, Below.
@rxova/ts-utils/env
The two environment questions and the warner built on them, on their own subpath. They are the
only exports that name process, and a bundler has to resolve that reference even when it sits
in a try: Turbopack reads it as node:process and refuses to put it in a service worker or an
edge chunk. Keeping them here means importing isRecord from the main entry cannot break such a
build, while the literal process.env.NODE_ENV stays literal for the bundlers that replace it.
| Export | What it answers |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| isDevelopment() | A boolean __DEV__ global wins; otherwise NODE_ENV !== "production". |
| isNonProduction({ bundlerEnv?, nodeEnv? }) | Conservative: PROD/DEV of the passed import.meta.env, then a string NODE_ENV; nothing known means false. |
Development warnings
createDevWarner(options) returns { warn, warnOnce, format, reset } for one package:
| Option | Default | Effect |
| --------- | --------------- | --------------------------------------------------------------- |
| prefix | — | Required. Every line starts [prefix]. |
| docsUrl | None | With a code, the line links <docsUrl>#<code in lower case>. |
| enabled | isDevelopment | Read on every call. |
| sink | console.warn | Where a warning goes. |
| Method | Does |
| -------------------------------------------- | ------------------------------------------------------------------------------ |
| warn(message, { code?, detail? }) | Warns every time. detail is logged as its own argument. |
| warnOnce(key, message, { code?, detail? }) | Warns the first time key is seen, so a render loop cannot flood the console. |
| format(message, code?) | The line a warning prints, without printing it. |
| reset() | Forgets every key warnOnce has seen. For tests. |
const warner = createDevWarner({ prefix: "otp", docsUrl: "https://example.com/warnings" });
warner.warn("length must be positive", { code: "OTP1001" });
// [otp] OTP1001: length must be positive
// → https://example.com/warnings#otp1001Types: DevWarner, DevWarnerOptions, WarnOptions, BundlerEnv, NonProductionOptions.
@rxova/ts-utils/react
| Export | What it is |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| useIsomorphicLayoutEffect | useLayoutEffect when a document exists, useEffect on the server. |
| useLatestRef(value) | A stable ref holding the latest committed value, written in a layout effect. |
| assignRef(ref, value) | Hands a value to a callback or object ref; returns a React 19 ref cleanup. |
| useMergedRefs(...refs) | One callback ref that feeds every ref given; stable while they are. |
| useMediaQuery(query, serverValue = false) | A media query kept current with useSyncExternalStore; serverValue on the server and without matchMedia. |
| useDevWarner({ prefix, docsUrl?, enabled?, onWarn? }) | createDevWarner per component instance; onWarn(detail) replaces the console. |
Types: UseDevWarnerOptions.
Notes
- Dead-code elimination.
isDevelopmentreadsprocess.env.NODE_ENVliterally, so a bundler can replace it. To drop a warning's text from a production bundle, guard the call site itself withif (process.env.NODE_ENV !== "production"). - Nothing in the main entry names
process.isDevelopment,isNonProductionandcreateDevWarnerlive on@rxova/ts-utils/envprecisely because they do, andrxova-repo-config check-bundlefails the build if one of them ever reachesdist/index.jsagain. Import the main entry from a service worker, an edge function or any chunk a bundler refuses to give Node builtins to; reach for/envwhere there is aprocessto read. - Seeds are stored data, so two of these never change.
seededRandom's sequence andhashString's number are part of the API: the same seed keeps giving the same order across minor releases. Neither is unpredictable —seededRandomleaks its 32-bit state in its output andhashStringis not cryptographic — so userandomBeloworrandomHexfor ids and tokens. randomBelowrejects rather than folds.getRandomValues(…) % boundfavours the low values unlessbounddivides 2³² exactly; the partial block is redrawn instead, which costs well under one extra draw on average and is uniform for every bound.stableStringifyisJSON.stringifywith sorted keys, and nothing else.toJSONruns first, so aDateis already its ISO string;undefined, functions and symbols are dropped from objects and becomenullin arrays; a cycle throws aTypeError. Keys sort by UTF-16 code unit, the order RFC 8785 specifies, so a hash is readable from another language — though Postgresjsonborders keys its own way and will not agree.readArrayand the empty list. A value that is not an array reads asundefined, never[], so "absent" and "empty" stay apart; write?? []where they should not.readItemreturnsundefinedfor an item it cannot read, andonInvaliddecides what that costs:reject(the default) loses the list,skiploses only that item.isRecordrejects arrays. Code that meant "any object" should useisObjectLike; code that must reject aDateor aMapshould useisPlainObject.- Two development checks.
isDevelopmentis permissive (an unsetNODE_ENVis development), for output such as warnings.isNonProductionis conservative (an unknown environment is production), for behaviour with a cost, such as switching on a devtools bridge. Pass it{ bundlerEnv: import.meta.env }from the call site; passingnodeEnv, even asundefined, stops it readingprocess.env.NODE_ENV. To keep warnings out of tests as well, passcreateDevWarner({ prefix, enabled: () => isDevelopment() && process.env.NODE_ENV !== "test" }). useDevWarneranddetail. Thedetailgiven towarn/warnOncegoes toonWarnas its only argument, so a component'sonWarn(warning)prop can be passed straight in; withoutonWarnthe console gets the formatted line alone.onWarnandenabledare read from the latest render,prefixanddocsUrlfrom the first. Keep the production guard at the call site,if (process.env.NODE_ENV !== "production") warner.warnOnce(…), so the message text is dropped.
