blank-to-null
v1.1.4
Published
Turn blank and undefined values into null, deeply, with zero dependencies.
Maintainers
Readme
blank-to-null
Turn blank and undefined values into null, deeply, with zero dependencies.
import blankToNull from 'blank-to-null';
blankToNull({ name: ' Ada ', bio: ' ', age: 0, tags: ['', 'js'] });
// { name: 'Ada', bio: null, age: 0, tags: [null, 'js'] }Table of contents
- The problem
- Install
- Quick start
- API
- Options
- Recipes
- TypeScript
- Behavior reference
- Limits
- Why not
||or?? - Contributing
- Sponsor
- License
The problem
An HTML form never sends null. An untouched text input submits "", and a field the
user cleared submits "" too. Databases and APIs want null for "not provided", so every
project ends up writing the same recursive walk to bridge the two.
The usual one-liner is wrong:
const bio = form.bio || null; // also turns 0 and false into null
const bio = form.bio ?? null; // leaves '' and ' ' untouchedblankToNull does the whole walk in one call, keeps 0/false/NaN intact, trims as it
goes, and never mutates the input.
Install
npm install blank-to-nullNode 18+. Zero runtime dependencies. Ships ESM, CommonJS, and TypeScript declarations for both.
Quick start
ESM
import blankToNull, { isBlank, pruneNull } from 'blank-to-null';CommonJS
const blankToNull = require('blank-to-null');
const { isBlank, pruneNull } = require('blank-to-null');TypeScript — no @types package needed, and no cast at the call site:
import blankToNull from 'blank-to-null';
const dto = blankToNull({ name: ' Ada ', age: 0 });
// ^? { name: string | null; age: number }Then:
blankToNull(' '); // null
blankToNull(' Ada '); // 'Ada'
blankToNull({ a: '', b: 0, c: false }); // { a: null, b: 0, c: false }
blankToNull([ '', 'js' ]); // [null, 'js']
blankToNull(undefined); // null
blankToNull(new Date('2020-01-01')); // same Date, by referenceAPI
Three functions. Two of them take options; none of them mutate anything.
blankToNull(input, options?)
Returns a copy of input where blank strings and undefined become null.
Strings are trimmed. Plain objects and arrays are rebuilt; every other object passes
through by reference.
blankToNull({ name: ' Ada ', bio: ' ', meta: { note: ' ' } });
// { name: 'Ada', bio: null, meta: { note: null } }Available as both a default and a named export:
import blankToNull from 'blank-to-null';
import { blankToNull } from 'blank-to-null'; // same functionisBlank(value, options?)
true for null, undefined, a blank string, [] and {}. Everything else — including
0, false and NaN — is not blank.
isBlank(null); // true
isBlank(undefined); // true
isBlank(''); // true
isBlank(' '); // true
isBlank(' ', { trim: false }); // false
isBlank([]); // true
isBlank({}); // true
isBlank(0); // false
isBlank(false); // false
isBlank(NaN); // false
isBlank(new Date()); // false
isBlank(new Map()); // false — not a plain objectOnly trim is read; the other options do not apply.
pruneNull(input)
Drops null and undefined object keys, so a PATCH body carries only what changed.
Array positions are preserved — dropping them would shift every later index.
pruneNull({ a: 1, b: null, c: undefined }); // { a: 1 }
pruneNull({ a: { b: null, c: 2 } }); // { a: { c: 2 } }
pruneNull([1, null, 2]); // [1, null, 2]It takes no options. Pair it with blankToNull when you want blanks gone rather than
nulled:
pruneNull(blankToNull({ name: ' Ada ', bio: ' ' }));
// { name: 'Ada' }Options
| Option | Default | Effect |
| --- | --- | --- |
| trim | true | Trim strings before the emptiness test, and in the output |
| deep | true | Walk nested plain objects and arrays. false converts the top level only |
| undefinedToNull | true | Convert undefined to null |
| emptyArrayToNull | false | Convert [] to null |
| emptyObjectToNull | false | Convert {} to null |
Every option is optional, and so is the options object itself. Passing undefined — or
null — for either keeps the defaults, so { trim: cfg.trim } on an absent config is not
an accidental opt-out:
blankToNull(form); // defaults
blankToNull(form, undefined); // defaults
blankToNull(form, null); // defaults
blankToNull(form, { trim: undefined }); // trim stays trueEach option in practice:
// trim — off means whitespace is content
blankToNull(' '); // null
blankToNull(' ', { trim: false }); // ' '
blankToNull(' a ', { trim: false }); // ' a '
// deep — off leaves nested containers alone, by reference
blankToNull({ bio: ' ', meta: { note: ' ' } }, { deep: false });
// { bio: null, meta: { note: ' ' } }
// undefinedToNull — off keeps undefined distinct from null
blankToNull({ a: undefined }); // { a: null }
blankToNull({ a: undefined }, { undefinedToNull: false }); // { a: undefined }
// emptyArrayToNull / emptyObjectToNull — collapse empty containers
blankToNull({ tags: [] }, { emptyArrayToNull: true }); // { tags: null }
blankToNull({ meta: {} }, { emptyObjectToNull: true }); // { meta: null }Collapsing happens after the walk, so a container that becomes empty only because its contents were blank collapses too:
blankToNull({ tags: ['', ' '] }, { emptyArrayToNull: true });
// { tags: [null, null] } — the array is not empty, it holds two nullsRecipes
Normalize a form before sending
const payload = blankToNull(Object.fromEntries(new FormData(formEl)));
await fetch('/api/users', { method: 'POST', body: JSON.stringify(payload) });PATCH only what changed
await api.patch(`/users/${id}`, pruneNull(blankToNull(form)));Express / Fastify — normalize every incoming body
app.use((req, _res, next) => {
if (req.body) req.body = blankToNull(req.body);
next();
});NestJS interceptor
@Injectable()
export class BlankToNullInterceptor implements NestInterceptor {
intercept(ctx: ExecutionContext, next: CallHandler) {
const req = ctx.switchToHttp().getRequest();
if (req.body) req.body = blankToNull(req.body);
return next.handle();
}
}Guard a required field
if (isBlank(form.email)) throw new Error('email is required');Keep undefined meaningful — some ORMs treat undefined as "leave alone" and null
as "set to NULL":
const update = blankToNull(form, { undefinedToNull: false });TypeScript
Declarations ship with the package — separate files for the ESM and CommonJS entry points,
so import and require both resolve correctly under node16, nodenext, bundler and
legacy node10. Verified by attw
in CI. TypeScript 4.9 and newer.
Exported types:
import type {
Blanked, // Blanked<T, Options> — result of blankToNull
Pruned, // Pruned<T> — result of pruneNull
BlankToNullOptions,
IsBlankOptions,
} from 'blank-to-null';null only where a value can actually be blank
The return type is computed from the input type, so a value that can never be blank never
gains | null:
type A = Blanked<string>; // string | null — could be ''
type B = Blanked<'ada'>; // 'ada' — never blank, never null
type C = Blanked<''>; // null — always blank
type D = Blanked<number>; // number — numbers are never blank
type E = Blanked<Date>; // Date — passes by referenceTrimming is modeled too, so the type matches what the runtime actually returns:
type F = Blanked<' a '>; // 'a' — the runtime trims it
type G = Blanked<' '>; // null — trims to '', so blank
type H = Blanked<' ', { trim: false }>; // ' ' — trim off, so it survivesThe options you pass change the type
The options object is read at the call site, not assumed:
blankToNull(undefined); // null
blankToNull(undefined, { undefinedToNull: false }); // undefined
blankToNull({ tags: [] }, { emptyArrayToNull: true });
// { tags: null }
blankToNull({ a: ' ', b: { c: ' ' } }, { deep: false });
// { a: string | null; b: { c: string } }An option whose type is a wide boolean — a runtime flag, not a literal — widens the
result to the union of both outcomes rather than guessing one:
declare const flag: boolean;
blankToNull(' ', { trim: flag }); // ' ' | nullTo keep the narrow type, use a literal or as const:
const opts = { trim: false } as const;
blankToNull(' ', opts); // ' 'Pruned<T>
A key that can be null/undefined becomes optional, a key that is always one of them
disappears, and a key that can be neither stays required:
type P1 = Pruned<{ a: number; b: string | null }>; // { a: number; b?: string }
type P2 = Pruned<{ a: null }>; // {}
type P3 = Pruned<{ a: number }>; // { a: number }
type P4 = Pruned<(number | null)[]>; // (number | null)[] — arrays keep positionsStructures the types handle
Tuples (including optional, rest and named elements), readonly arrays and properties,
index signatures, symbol keys, unions, enums, template-literal string types, class
instances, and recursive or mutually recursive interfaces all round-trip correctly. The
package is covered by 159 type assertions run against both module formats, on every
supported TypeScript version, in CI.
Behavior reference
- Nothing is mutated. Objects and arrays are rebuilt; the input is untouched.
- Only plain objects and arrays are walked.
Date,Map,Set,RegExp,Promise, functions, class instances and boxed primitives pass through by reference, unchanged. - Falsy values survive.
0,false,NaNand0nare data, not blanks. - Circular references are safe. A cycle is preserved in the copy, not re-walked.
__proto__stays data. A parsed body like{"__proto__": {...}}keeps that key as an own property; the copy's prototype is never rewritten, and the key is never dropped.- Null-prototype objects (
Object.create(null)) count as plain objects, and the copy keeps the null prototype. - Enumerable symbol keys are copied. Non-enumerable properties, and extra properties hung off an array, are not.
- Getters are read once and copied as plain data — the copy has a value, not an accessor.
- Array holes become
null, since a hole reads asundefinedand is converted like any other value. - Key order follows JavaScript's own rule: integer-like keys come first, in ascending order, then string keys in insertion order, then symbols.
pruneNullis a single pass. An object that loses all of its keys is left as{}, not removed from its parent.
Limits
- Depth. The walk is recursive, so a pathological structure can exhaust the stack.
blankToNullhandles a few thousand levels of nesting — around 3000-4000, varying with the available stack — and throwsRangeErrorbeyond that.JSON.parsein V8 is iterative and has no comparable limit, so a hostile body can parse and still fail to convert. The error is catchable: wrap the call, or cap request depth, if you accept untrusted deeply-nested JSON. - Wide
booleanoptions widen the result type to a union — see TypeScript. Blankedreflects the input type, not the literal you wrote. TypeScript widens object-literal properties, soblankToNull({ a: '' })typesaasstring | null, notnull. Addas constif you want literal precision.
Why not || or ??
const bio = form.bio || null; // 0 and false become null too
const bio = form.bio ?? null; // '' and ' ' pass straight throughNeither trims, neither recurses, and both have to be repeated for every field.
Contributing
npm install
npm test # runtime tests (node --test)
npm run test:types # type assertions, ESM + CJS, via tsc
npm run test:package # package resolution check (attw)
npm run test:all # all threeThe runtime lives in src/index.js (CommonJS) and src/index.mjs is a thin ESM
wrapper. No file in the package uses the .cjs extension, deliberately:
create-react-app 5 routes any imported .cjs through its catch-all asset rule and
hands back a URL string instead of the module, so the default export stops being a
function while the build still succeeds. Type declarations are src/types.d.ts
(shared) plus src/index.d.mts and src/index.d.ts (entry points). Type assertions
live in test-d/assert.mts; the CommonJS variant is generated from it by
test-d/gen-cts.mjs, so the two can never drift — edit the .mts file only.
Issues and pull requests: https://github.com/gitsult4n/blank-to-null
Sponsor
This package is small on purpose: one problem, solved carefully, with no dependencies and no surprises. Keeping it that way is quiet work — reading the spec instead of guessing, writing the test for the edge case nobody hits until production, and saying no to features that would make it heavier for everyone.
If blank-to-null saved you an afternoon of writing the same recursive walk again, you can
sponsor the work on GitHub. Sponsorship pays for
the time that goes into the parts you never see: keeping the type tests honest across every
supported TypeScript version, answering issues properly, and holding the API steady so
upgrades stay boring.
Not in a position to sponsor? A star, a bug report with a reproduction, or telling one other developer about it helps just as much, and is always welcome.
License
MIT © gitsult4n
