Zero-dependency utilities for common TypeScript operations in dean-stack. Decision order — apply top-down, stop at the first match:
- Native ECMAScript / Web API → use it (no install).
Bun.* built-in (tooling/scripts only — never in browser code).
- One-line typed inline helper co-located with the call site.
just-* micro-package (bun add).
memoize-one for "cache the last call" — module scope only.
This is a cross-cutting authoring policy, not a tech. Anything that touches an owned dean-stack tech (state, storage, schemas, animation, canvas, routing, motion) defers to that tech's skill.
When to invoke
- Reaching for
lodash, lodash-es, ramda, underscore, moment, or uuid — almost always there's a native or just-* answer.
- Manipulating objects (deep merge, deep clone, deep diff), arrays (group, partition, dedupe, sort, set ops), strings (case conversion, padding), numbers (clamp, format), or functions (debounce, throttle, memoize) in plain TS.
- Merging user input or generator answers over a defaults object.
- Choosing between
useMemo and memoize-one for caching an expensive transform.
When NOT to invoke
- Schema validation →
zod (Pillar 2). No Valibot, Yup, Joi, AJV.
- React state →
jotai. No Zustand, Redux, Recoil, TanStack Query.
- Persistence →
idb. No localForage, Dexie.
- Animation →
animejs via useAnime.
- Canvas / 2D →
pixijs via usePixiApp.
- Date arithmetic — no date library is pinned in dean-stack. Surface a stack-pin decision with the user before adding
date-fns or anything else; Intl.DateTimeFormat covers display-only needs.
Owns
The decision order above, the native-first preference, the just-* adoption rules, the memoize-one-vs-Compiler call, and the anti-patterns that flag lodash / JSON.parse(JSON.stringify(…)) / moment / node-fetch / dotenv / bcrypt.
Defers to
zod — every boundary input is parsed by a Zod schema; this skill does not validate.
jotai — atom authoring + the atomWithIDB factory; state helpers live there.
idb — IDB primitives + the root hydration promise; persistence helpers live there.
react-compiler-rules — the "no manual useMemo / useCallback / React.memo" rule. memoize-one inside a component body is forbidden by that rule.
react-19-primitives — useMemo / useTransition / useDeferredValue are the right answer when the cache is component-scoped.
bun-runtime — the Bun.* API surface; tooling/scripts only, never in Vite-bundled browser code.
bun-package-manager — for bun add invocations and the workspace packageManager pin.
ts — strict-mode rules every helper must satisfy (noUncheckedIndexedAccess, exactOptionalPropertyTypes, verbatimModuleSyntax).
biome — every helper file is linted; no exemption.
Dean-stack rules
- Pillar 4 (CLI-gate-first): every helper must compile under
tsgo --noEmit strict and lint clean under Biome before bun run check will pass. No any, no as casts at module boundaries (Pillar 2 — parse with Zod instead).
- Bundle hygiene matters more than usual. Dean-stack ships static to GH Pages and reloads live on iPad over LAN. Prefer the native API or a 3-line inline helper over a 1KB
just-* package; prefer a 1KB just-* package over a 70KB lodash import.
Bun.* is browser-forbidden. Bun.deepEquals, Bun.escapeHTML, Bun.password, Bun.file, Bun.$, Bun.serve are tooling/scripts only (see bun-runtime). Vite bundles browser code; Bun.* symbols don't exist there. For browser code use the native column or write inline.
- Side-channel rule applies to debounce/throttle.
just-debounce-it / just-throttle mutate captured-ref state across calls — same shape as anime.js / PixiJS (see react-compiler-rules). Hoist them to module scope or wrap in a useEffect-only pattern; never recreate the debounced function in render. Do NOT reach for useCallback to stabilize them — let the Compiler memoize the JSX and keep the function reference stable via module scope.
memoize-one only at module scope. Inside a component, the Compiler memoizes; manual memoize-one is forbidden by Pillar 4. The legitimate use is const computeStyles = memoizeOne(buildStyles) once at the top of a file.
- No date library is pinned. Adding
date-fns, dayjs, moment, or anything else is a stack-pin change — surface it with the user first. Display-only formatting goes through Intl.DateTimeFormat.
Patterns
Native-first reference (use these before any package)
| API |
Use case |
structuredClone(obj) |
Deep clone — handles Date / Map / Set / RegExp / cycles. Replaces JSON.parse(JSON.stringify(...)). |
Object.groupBy(arr, fn) |
Group array by string key |
Map.groupBy(arr, fn) |
Same, key can be any type (returns Map) |
arr.toSorted(fn) / arr.toReversed() / arr.toSpliced(...) / arr.with(i, v) |
Immutable array ops |
arr.at(-1) / arr.findLast(fn) |
Tail access |
arr.flat(depth) |
Flatten nested arrays |
arr.filter(Boolean) |
Drop falsy |
[...new Set(arr)] |
Dedupe primitives |
Object.hasOwn(obj, key) |
Safer hasOwnProperty |
Object.fromEntries(Object.entries(obj).map(...)) |
Map values / keys |
str.replaceAll(find, replace) |
Replace all |
str.padStart(n, ch) / str.padEnd(n, ch) |
Padding |
obj?.a?.b?.c |
Safe nested get |
Promise.withResolvers() |
External resolve/reject |
AbortSignal.timeout(ms) |
Cancellation |
new Intl.NumberFormat(locale, opts).format(n) |
Currency / compact / unit display |
new Intl.DateTimeFormat(locale, opts).format(d) |
Date display (NOT arithmetic) |
Bun.* reference (tooling/scripts only — bun-runtime)
| Bun API |
Replaces |
Bun.deepEquals(a, b) (strict mode: third arg true) |
lodash.isEqual, just-compare |
Bun.escapeHTML(str) |
he, escape-html |
Bun.semver.satisfies(v, range) / Bun.semver.order(a, b) |
semver |
Bun.file(path).text() / .json() / .bytes() |
fs.readFile |
Bun.write(path, data) |
fs.writeFile |
Bun.$`cmd` |
execa, zx |
For Vite-bundled browser code (apps/web/app/...), use the native column or write a typed helper inline.
Type-safe helpers (preferred over just-pick / just-omit when types must stay precise)
export function pick<T extends object, K extends keyof T>(obj: T, keys: readonly K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
for (const key of keys) if (key in obj) result[key] = obj[key];
return result;
}
export function omit<T extends object, K extends keyof T>(obj: T, keys: readonly K[]): Omit<T, K> {
const result = { ...obj };
for (const key of keys) delete result[key];
return result as Omit<T, K>;
}
export function groupBy<T, K extends PropertyKey>(
arr: readonly T[],
getKey: (item: T) => K,
): Partial<Record<K, T[]>> {
return arr.reduce<Partial<Record<K, T[]>>>((acc, item) => {
const key = getKey(item);
(acc[key] ??= []).push(item);
return acc;
}, {});
}
export function dedupeBy<T, K>(arr: readonly T[], getKey: (item: T) => K): T[] {
const seen = new Set<K>();
return arr.filter((x) => {
const k = getKey(x);
if (seen.has(k)) return false;
seen.add(k);
return true;
});
}
export function clamp(n: number, min: number, max: number): number {
return Math.min(Math.max(n, min), max);
}
These compile clean under tsgo --noEmit strict. Co-locate with the call site; only promote to a shared file when ≥2 callers exist with identical signatures.
just-* packages — when to add
| Need |
Package |
Notes |
| Deep merge user config over defaults |
just-extend |
extend(true, {}, defaults, user) (deep). |
| Deep diff (JSON-patch ops) |
just-diff + just-diff-apply |
|
| Multi-key sort |
just-order-by |
When toSorted ergonomics aren't enough. |
| Shuffle |
just-shuffle |
Fisher-Yates. |
| Debounce / throttle |
just-debounce-it / just-throttle |
See side-channel rule above. |
| Run-once |
just-once |
|
| Last-call cache (module scope) |
memoize-one |
Forbidden inside components — Compiler memoizes. |
| Case conversion |
just-camel-case, just-kebab-case, just-snake-case, just-pascal-case |
One per case. |
| Truncate |
just-truncate (chars) / just-prune (word boundary) |
|
| Statistics |
just-median, just-percentile, just-variance, just-standard-deviation |
One per stat. |
bun add <package>. Each is <1KB minified, zero deps. If two workspaces use the same just-*, pin via the root package.json so versions stay in sync (see bun-package-manager).
Dean-stack patterns
Deep clone an IDB record before mutating — structuredClone handles Date / Map / Set, which IDB records can contain. Never JSON.parse(JSON.stringify(...)).
Merge generator answers over template defaults (in turbo/generators/config.ts):
import extend from "just-extend";
const merged = extend(true, {}, templateDefaults, userAnswers);
Debounce a search input inside a route component — declare at module scope, not in the component body:
// At module scope.
import debounce from "just-debounce-it";
import { useSetAtom } from "jotai"; // see `jotai`
import { searchAtom } from "./atoms";
const queueSearch = debounce((query: string, set: (q: string) => void) => {
set(query);
}, 300);
function SearchInput() {
const setQuery = useSetAtom(searchAtom);
return <input => queueSearch(e.target.value, setQuery)} />;
}
The Compiler memoizes the JSX; queueSearch is reference-stable because it's at module scope. Don't wrap in useCallback (see react-compiler-rules).
Module-scope memoize-one for an expensive transform:
import memoizeOne from "memoize-one";
const computeTokens = memoizeOne((mode: "light" | "dark", accent: string) => buildTokens(mode, accent));
Defined once at the top of a file. useMemo would re-cache per-component; module scope caches across the whole app.
Anti-patterns
- Don't
JSON.parse(JSON.stringify(obj)) — drops Date / Map / Set / RegExp, throws on functions/symbols, breaks IDB records. Use structuredClone(obj).
- Don't import
lodash / lodash-es — too heavy for the iPad-LAN budget; everything is now native or just-*.
- Don't import
moment — not pinned in this stack; surface a date-library decision with the user first.
- Don't
arr.sort() when you don't intend to mutate — use arr.toSorted().
- Don't use
Bun.* in browser code — Vite bundles it and the symbol won't exist at runtime. Tooling/scripts only (see bun-runtime).
- Don't put
memoize-one inside a component body — Compiler memoizes; manual memo is forbidden (see react-compiler-rules). Module scope is the only valid use.
- Don't recreate
debounce(...) / throttle(...) in render — module scope or useEffect-mounted only. Same side-channel rule as anime.js / PixiJS.
- Don't reach for
useCallback to stabilize a debounced function — hoist it to module scope; the Compiler handles the rest.
- Don't import
node-fetch / cross-fetch — fetch is global on Bun and Node 21+.
- Don't import
dotenv — Bun loads .env automatically; build-time vars go through t3-env.
- Don't import
bcrypt / argon2 — there is no server in dean-stack; auth doesn't apply on GH Pages.
- Don't validate with anything other than Zod — Pillar 2; Valibot / Yup / Joi / AJV are forbidden (see
zod).
- Don't grow a shared
utils/ graveyard — co-locate small helpers with their call sites; only promote to a shared file when ≥2 callers exist with identical signatures.
Triggers on
structuredClone, deep clone, deep merge, deep equality, group by, partition, pick omit, debounce, throttle, memoize-one, just-extend, micro utilities, lodash alternative
1---2name: micro-utilities3description: Zero-dependency TypeScript utilities for dean-stack — native ECMAScript first (`structuredClone`, `Object.groupBy`, `Map.groupBy`, `toSorted`, `replaceAll`, `Intl.NumberFormat`), then Bun built-ins (`Bun.deepEquals`, `Bun.escapeHTML`, `Bun.password`, `Bun.file`) for tooling/scripts only, then a one-line typed inline helper, then `just-*` micro-packages, then `memoize-one` (module scope only). Calibrated for Bun 1.3.13 / Node 25 / TypeScript 7 with React 19 + Compiler. Triggers on: structuredClone, deep clone, deep merge, deep equality, group by, partition, pick omit, debounce, throttle, memoize-one, just-extend, micro utilities, lodash alternative.4license: MIT5---67Zero-dependency utilities for common TypeScript operations in dean-stack. Decision order — apply top-down, stop at the first match:891. **Native ECMAScript / Web API** → use it (no install).102. **`Bun.*` built-in** (tooling/scripts only — never in browser code).113. **One-line typed inline helper** co-located with the call site.124. **`just-*` micro-package** (`bun add`).135. **`memoize-one`** for "cache the last call" — module scope only.1415This is a cross-cutting authoring policy, not a tech. Anything that touches an owned dean-stack tech (state, storage, schemas, animation, canvas, routing, motion) defers to that tech's skill.1617## When to invoke18- Reaching for `lodash`, `lodash-es`, `ramda`, `underscore`, `moment`, or `uuid` — almost always there's a native or `just-*` answer.19- Manipulating objects (deep merge, deep clone, deep diff), arrays (group, partition, dedupe, sort, set ops), strings (case conversion, padding), numbers (clamp, format), or functions (debounce, throttle, memoize) in plain TS.20- Merging user input or generator answers over a defaults object.21- Choosing between `useMemo` and `memoize-one` for caching an expensive transform.2223## When NOT to invoke24- **Schema validation** → `zod` (Pillar 2). No Valibot, Yup, Joi, AJV.25- **React state** → `jotai`. No Zustand, Redux, Recoil, TanStack Query.26- **Persistence** → `idb`. No localForage, Dexie.27- **Animation** → `animejs` via `useAnime`.28- **Canvas / 2D** → `pixijs` via `usePixiApp`.29- **Date arithmetic** — no date library is pinned in dean-stack. Surface a stack-pin decision with the user before adding `date-fns` or anything else; `Intl.DateTimeFormat` covers display-only needs.3031## Owns32The decision order above, the native-first preference, the `just-*` adoption rules, the `memoize-one`-vs-Compiler call, and the anti-patterns that flag `lodash` / `JSON.parse(JSON.stringify(…))` / `moment` / `node-fetch` / `dotenv` / `bcrypt`.3334## Defers to35- `zod` — every boundary input is parsed by a Zod schema; this skill does not validate.36- `jotai` — atom authoring + the `atomWithIDB` factory; state helpers live there.37- `idb` — IDB primitives + the root hydration promise; persistence helpers live there.38- `react-compiler-rules` — the "no manual `useMemo` / `useCallback` / `React.memo`" rule. `memoize-one` inside a component body is forbidden by that rule.39- `react-19-primitives` — `useMemo` / `useTransition` / `useDeferredValue` are the right answer when the cache is component-scoped.40- `bun-runtime` — the `Bun.*` API surface; tooling/scripts only, never in Vite-bundled browser code.41- `bun-package-manager` — for `bun add` invocations and the workspace `packageManager` pin.42- `ts` — strict-mode rules every helper must satisfy (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `verbatimModuleSyntax`).43- `biome` — every helper file is linted; no exemption.4445## Dean-stack rules46- **Pillar 4 (CLI-gate-first):** every helper must compile under `tsgo --noEmit` strict and lint clean under Biome before `bun run check` will pass. No `any`, no `as` casts at module boundaries (Pillar 2 — parse with Zod instead).47- **Bundle hygiene matters more than usual.** Dean-stack ships static to GH Pages and reloads live on iPad over LAN. Prefer the native API or a 3-line inline helper over a 1KB `just-*` package; prefer a 1KB `just-*` package over a 70KB `lodash` import.48- **`Bun.*` is browser-forbidden.** `Bun.deepEquals`, `Bun.escapeHTML`, `Bun.password`, `Bun.file`, `Bun.$`, `Bun.serve` are tooling/scripts only (see `bun-runtime`). Vite bundles browser code; `Bun.*` symbols don't exist there. For browser code use the native column or write inline.49- **Side-channel rule applies to debounce/throttle.** `just-debounce-it` / `just-throttle` mutate captured-ref state across calls — same shape as anime.js / PixiJS (see `react-compiler-rules`). Hoist them to module scope or wrap in a `useEffect`-only pattern; never recreate the debounced function in render. Do NOT reach for `useCallback` to stabilize them — let the Compiler memoize the JSX and keep the function reference stable via module scope.50- **`memoize-one` only at module scope.** Inside a component, the Compiler memoizes; manual `memoize-one` is forbidden by Pillar 4. The legitimate use is `const computeStyles = memoizeOne(buildStyles)` once at the top of a file.51- **No date library is pinned.** Adding `date-fns`, `dayjs`, `moment`, or anything else is a stack-pin change — surface it with the user first. Display-only formatting goes through `Intl.DateTimeFormat`.5253## Patterns5455### Native-first reference (use these before any package)5657| API | Use case |58|---|---|59| `structuredClone(obj)` | Deep clone — handles `Date` / `Map` / `Set` / `RegExp` / cycles. Replaces `JSON.parse(JSON.stringify(...))`. |60| `Object.groupBy(arr, fn)` | Group array by string key |61| `Map.groupBy(arr, fn)` | Same, key can be any type (returns `Map`) |62| `arr.toSorted(fn)` / `arr.toReversed()` / `arr.toSpliced(...)` / `arr.with(i, v)` | Immutable array ops |63| `arr.at(-1)` / `arr.findLast(fn)` | Tail access |64| `arr.flat(depth)` | Flatten nested arrays |65| `arr.filter(Boolean)` | Drop falsy |66| `[...new Set(arr)]` | Dedupe primitives |67| `Object.hasOwn(obj, key)` | Safer `hasOwnProperty` |68| `Object.fromEntries(Object.entries(obj).map(...))` | Map values / keys |69| `str.replaceAll(find, replace)` | Replace all |70| `str.padStart(n, ch)` / `str.padEnd(n, ch)` | Padding |71| `obj?.a?.b?.c` | Safe nested get |72| `Promise.withResolvers()` | External resolve/reject |73| `AbortSignal.timeout(ms)` | Cancellation |74| `new Intl.NumberFormat(locale, opts).format(n)` | Currency / compact / unit display |75| `new Intl.DateTimeFormat(locale, opts).format(d)` | Date display (NOT arithmetic) |7677### `Bun.*` reference (tooling/scripts only — `bun-runtime`)7879| Bun API | Replaces |80|---|---|81| `Bun.deepEquals(a, b)` *(strict mode: third arg `true`)* | `lodash.isEqual`, `just-compare` |82| `Bun.escapeHTML(str)` | `he`, `escape-html` |83| `Bun.semver.satisfies(v, range)` / `Bun.semver.order(a, b)` | `semver` |84| `Bun.file(path).text()` / `.json()` / `.bytes()` | `fs.readFile` |85| `Bun.write(path, data)` | `fs.writeFile` |86| `` Bun.$`cmd` `` | `execa`, `zx` |8788For Vite-bundled browser code (`apps/web/app/...`), use the native column or write a typed helper inline.8990### Type-safe helpers (preferred over `just-pick` / `just-omit` when types must stay precise)9192```ts93export function pick<T extends object, K extends keyof T>(obj: T, keys: readonly K[]): Pick<T, K> {94 const result = {} as Pick<T, K>;95 for (const key of keys) if (key in obj) result[key] = obj[key];96 return result;97}9899export function omit<T extends object, K extends keyof T>(obj: T, keys: readonly K[]): Omit<T, K> {100 const result = { ...obj };101 for (const key of keys) delete result[key];102 return result as Omit<T, K>;103}104105export function groupBy<T, K extends PropertyKey>(106 arr: readonly T[],107 getKey: (item: T) => K,108): Partial<Record<K, T[]>> {109 return arr.reduce<Partial<Record<K, T[]>>>((acc, item) => {110 const key = getKey(item);111 (acc[key] ??= []).push(item);112 return acc;113 }, {});114}115116export function dedupeBy<T, K>(arr: readonly T[], getKey: (item: T) => K): T[] {117 const seen = new Set<K>();118 return arr.filter((x) => {119 const k = getKey(x);120 if (seen.has(k)) return false;121 seen.add(k);122 return true;123 });124}125126export function clamp(n: number, min: number, max: number): number {127 return Math.min(Math.max(n, min), max);128}129```130131These compile clean under `tsgo --noEmit` strict. Co-locate with the call site; only promote to a shared file when ≥2 callers exist with identical signatures.132133### `just-*` packages — when to add134135| Need | Package | Notes |136|---|---|---|137| Deep merge user config over defaults | `just-extend` | `extend(true, {}, defaults, user)` (deep). |138| Deep diff (JSON-patch ops) | `just-diff` + `just-diff-apply` | |139| Multi-key sort | `just-order-by` | When `toSorted` ergonomics aren't enough. |140| Shuffle | `just-shuffle` | Fisher-Yates. |141| Debounce / throttle | `just-debounce-it` / `just-throttle` | See side-channel rule above. |142| Run-once | `just-once` | |143| Last-call cache (module scope) | `memoize-one` | Forbidden inside components — Compiler memoizes. |144| Case conversion | `just-camel-case`, `just-kebab-case`, `just-snake-case`, `just-pascal-case` | One per case. |145| Truncate | `just-truncate` (chars) / `just-prune` (word boundary) | |146| Statistics | `just-median`, `just-percentile`, `just-variance`, `just-standard-deviation` | One per stat. |147148`bun add <package>`. Each is <1KB minified, zero deps. If two workspaces use the same `just-*`, pin via the root `package.json` so versions stay in sync (see `bun-package-manager`).149150### Dean-stack patterns151152**Deep clone an IDB record before mutating** — `structuredClone` handles `Date` / `Map` / `Set`, which IDB records can contain. Never `JSON.parse(JSON.stringify(...))`.153154**Merge generator answers over template defaults** (in `turbo/generators/config.ts`):155156```ts157import extend from "just-extend";158const merged = extend(true, {}, templateDefaults, userAnswers);159```160161**Debounce a search input inside a route component** — declare at module scope, not in the component body:162163```tsx164// At module scope.165import debounce from "just-debounce-it";166import { useSetAtom } from "jotai"; // see `jotai`167import { searchAtom } from "./atoms";168169const queueSearch = debounce((query: string, set: (q: string) => void) => {170 set(query);171}, 300);172173function SearchInput() {174 const setQuery = useSetAtom(searchAtom);175 return <input onChange={(e) => queueSearch(e.target.value, setQuery)} />;176}177```178179The Compiler memoizes the JSX; `queueSearch` is reference-stable because it's at module scope. Don't wrap in `useCallback` (see `react-compiler-rules`).180181**Module-scope `memoize-one` for an expensive transform:**182183```ts184import memoizeOne from "memoize-one";185const computeTokens = memoizeOne((mode: "light" | "dark", accent: string) => buildTokens(mode, accent));186```187188Defined once at the top of a file. `useMemo` would re-cache per-component; module scope caches across the whole app.189190## Anti-patterns191192- **Don't `JSON.parse(JSON.stringify(obj))`** — drops `Date` / `Map` / `Set` / `RegExp`, throws on functions/symbols, breaks IDB records. Use `structuredClone(obj)`.193- **Don't import `lodash` / `lodash-es`** — too heavy for the iPad-LAN budget; everything is now native or `just-*`.194- **Don't import `moment`** — not pinned in this stack; surface a date-library decision with the user first.195- **Don't `arr.sort()` when you don't intend to mutate** — use `arr.toSorted()`.196- **Don't use `Bun.*` in browser code** — Vite bundles it and the symbol won't exist at runtime. Tooling/scripts only (see `bun-runtime`).197- **Don't put `memoize-one` inside a component body** — Compiler memoizes; manual memo is forbidden (see `react-compiler-rules`). Module scope is the only valid use.198- **Don't recreate `debounce(...)` / `throttle(...)` in render** — module scope or `useEffect`-mounted only. Same side-channel rule as anime.js / PixiJS.199- **Don't reach for `useCallback` to stabilize a debounced function** — hoist it to module scope; the Compiler handles the rest.200- **Don't import `node-fetch` / `cross-fetch`** — `fetch` is global on Bun and Node 21+.201- **Don't import `dotenv`** — Bun loads `.env` automatically; build-time vars go through `t3-env`.202- **Don't import `bcrypt` / `argon2`** — there is no server in dean-stack; auth doesn't apply on GH Pages.203- **Don't validate with anything other than Zod** — Pillar 2; Valibot / Yup / Joi / AJV are forbidden (see `zod`).204- **Don't grow a shared `utils/` graveyard** — co-locate small helpers with their call sites; only promote to a shared file when ≥2 callers exist with identical signatures.205206## Triggers on207structuredClone, deep clone, deep merge, deep equality, group by, partition, pick omit, debounce, throttle, memoize-one, just-extend, micro utilities, lodash alternative