Skip to content

@absoluteui/helpers/sanitize

Record cleanup, file-name sanitizing, invisible-character removal, PII masking, and redaction.

import { ... } from '@absoluteui/helpers/sanitize';

Clean data on the way in and scrub it on the way out: normalise imported rows, contact details, and file names, strip invisible characters, and redact secrets and personal data before logging. It is not an HTML sanitizer: put untrusted text into the page with textContent, and check URLs with @absoluteui/helpers/url. The Helpers and services guide lists the security defaults shared by all helpers.

How do I clean rows from an import before saving them?

Section titled “How do I clean rows from an import before saving them?”

cleanRecords runs one pipeline: normalise each row (trim, strip invisible characters, lowercase keys, empty strings to null), drop empty rows, remove duplicates, filter, and map. coerceRecordTypes then turns strings into numbers, booleans, and dates.

import { parseCsv } from '@absoluteui/helpers/csv';
import { cleanRecords, coerceRecordTypes } from '@absoluteui/helpers/sanitize';
const { rows } = parseCsv('Email,Name,FTE,Active\n ASHA@example.com , Asha Rao ,1,yes\n,,,\nasha@example.com,Asha Rao,0.8,yes\nliam@example.com,Liam Chen,x,no\n', { header: true });
const clean = cleanRecords(rows, {
normalize: { lowercaseKeys: true, collapseWhitespace: true, emptyToNull: true, lowercase: ['email'] },
dedupeBy: (r) => r.email,
keep: 'last',
map: (r) => coerceRecordTypes(r, { fte: 'number', active: 'boolean' }),
});
clean; // => [{ email: 'asha@example.com', name: 'Asha Rao', fte: 0.8, active: true }, { email: 'liam@example.com', name: 'Liam Chen', fte: null, active: false }]

Unparseable values become null by default; pass { onInvalid: 'keep' } or 'omit' to change that.

How do I log an object without leaking secrets or personal data?

Section titled “How do I log an object without leaking secrets or personal data?”

redact replaces values under secret-looking keys (passwords, tokens, Authorization, API keys); add your own keys, and maskStrings: true masks e-mails, phone and card numbers inside the remaining text. truncateForLog keeps the line short.

import { DEFAULT_REDACT_KEYS, maskPii, redact, truncateForLog } from '@absoluteui/helpers/sanitize';
const event = { user: 'E-1001', headers: { Authorization: 'Bearer abc.def' }, body: { note: 'Call asha@example.com', bankAccount: 'DE89370400440532013000' } };
const safe = redact(event, { keys: [...DEFAULT_REDACT_KEYS, /bankAccount/i], maskStrings: true });
safe; // => { user: 'E-1001', headers: { Authorization: '[REDACTED]' }, body: { note: 'Call a***@example.com', bankAccount: '[REDACTED]' } }
event.headers.Authorization; // => 'Bearer abc.def' // the input is not changed
maskPii('Card 4111 1111 1111 1111 declined'); // => 'Card **** **** **** 1111 declined'
truncateForLog(safe, 30).length; // => 30

These are heuristics for logs and previews; the safest data is the data you never log.

How do I build a file name from user data?

Section titled “How do I build a file name from user data?”

safeFileName produces one valid name on Windows, macOS, and Linux: path separators and control characters go, reserved device names are escaped, and long names are shortened without losing the extension.

import { safeFileName } from '@absoluteui/helpers/sanitize';
safeFileName('Payslip: Rao/Asha 2026-09.pdf'); // => 'Payslip_ Rao_Asha 2026-09.pdf'
safeFileName('NUL.csv'); // => '_NUL.csv'
safeFileName(`${'Quarterly headcount report '.repeat(20)}.xlsx`, { maxLength: 40 }).endsWith('.xlsx'); // => true
safeFileName('...', { fallback: 'export.csv' }); // => 'export.csv'

How do I normalise contact details before matching them?

Section titled “How do I normalise contact details before matching them?”

Normalise before comparing or storing, so Asha@Example.com and asha@example.com are one person. stripInvisible removes zero-width and bidi control characters that make two names look equal when they are not.

import { normalizeEmail, normalizePhone, stripInvisible } from '@absoluteui/helpers/sanitize';
normalizeEmail(' Asha.Rao@Example.COM ', { lowercaseLocal: true }); // => 'asha.rao@example.com'
normalizeEmail('not-an-email'); // => undefined
normalizePhone('080 5550 1234', { defaultCountryCode: '91' }); // => '+918055501234'
const typed = 'asha' + String.fromCharCode(0x200b) + '.rao';
typed === 'asha.rao'; // => false
stripInvisible(typed) === 'asha.rao'; // => true
  • sanitizeSearchQuery cleans text for display and matching, but it is not an injection defence: always use parameterised queries on the server.
  • maskPii misses formats it does not recognise, and may mask a number that only looks like a card.
  • normalizePhone works on digits only; it does not know numbering plans, so it cannot tell a valid number from a well-formed one.
import { normalizePhone, sanitizeSearchQuery } from '@absoluteui/helpers/sanitize';
sanitizeSearchQuery(" Rao'; DROP TABLE staff;-- "); // => "Rao'; DROP TABLE staff;--"
normalizePhone('12'); // => undefined // too few digits for E.164

maskPii

Masks e-mail addresses, phone numbers, card numbers, IBANs and SSN-shaped numbers in free text (for logs, support tickets, previews). Heuristic: never a substitute for not logging PII.

function maskPii(text: string, options?: MaskPiiOptions): string;

Example:

maskPii('Mail ada@example.com, card 4111 1111 1111 1111') // => 'Mail a***@example.com, card **** **** **** 1111'

redact

Returns a copy with values under secret-looking keys replaced (for logs and telemetry). Plain objects, arrays and Maps are copied; other objects are kept by reference. Cycles become '[Circular]'; unsafe keys (__proto__…) are dropped.

function redact<T>(value: T, options?: RedactOptions): T;

Example:

redact({ user: 'ada', password: 'x', headers: { Authorization: 'Bearer t' } })
// { user: 'ada', password: '[REDACTED]', headers: { Authorization: '[REDACTED]' } }

DEFAULT_REDACT_KEYS

Default secret-looking key pattern for {@link redact}.

const DEFAULT_REDACT_KEYS: readonly RegExp[];

Example:

redact({ name: 'Ada', ssn: '123-45-6789', apiKey: 'k1' }, { keys: [...DEFAULT_REDACT_KEYS, /ssn/i] }); // => { name: 'Ada', ssn: '[REDACTED]', apiKey: '[REDACTED]' }

MaskPiiOptions

interface MaskPiiOptions {
readonly email?: boolean;
readonly phone?: boolean;
readonly card?: boolean;
readonly iban?: boolean;
readonly ssn?: boolean;
readonly maskChar?: string;
readonly maxLength?: number;
}

RedactOptions

interface RedactOptions {
readonly keys?: readonly (RegExp | string)[];
readonly replacement?: unknown;
readonly deep?: boolean;
readonly maxDepth?: number;
readonly maskStrings?: boolean | MaskPiiOptions;
}

cleanRecords

Cleaning pipeline for imported rows: normalize -> drop empty rows -> dedupe -> filter -> map.

function cleanRecords<T = Record<string, unknown>>(records: readonly Readonly<Record<string, unknown>>[], options?: CleanRecordsOptions<T>): T[];

Example:

const rows = [{ name: ' Ada ', email: 'ada@example.com' }, { name: '', email: '' }, { name: 'Ada L.', email: 'ada@example.com' }];
cleanRecords(rows, { normalize: { emptyToNull: true }, dedupeBy: (r) => r.email, keep: 'last' }); // => [{ name: 'Ada L.', email: 'ada@example.com' }]

coerceRecordTypes

Converts record fields from strings to the listed types (CSV/form import). date yields a Date from an ISO 8601 string; json parses JSON text (max 100 000 characters).

function coerceRecordTypes(record: Readonly<Record<string, unknown>>, types: Readonly<Record<string, CoerceType>>, options?: CoerceOptions): Record<string, unknown>;

Example:

coerceRecordTypes({ n: '4', ok: 'yes' }, { n: 'integer', ok: 'boolean' }) // => { n: 4, ok: true }

dedupeBy

Removes duplicates by a key (compared with SameValueZero), in O(n).

function dedupeBy<T>(records: readonly T[], keyFn: (record: T, index: number) => unknown, options?: DedupeOptions): T[];

Example:

dedupeBy([{ id: 1, v: 'a' }, { id: 1, v: 'b' }], (r) => r.id, { keep: 'last' }) // => [{ id: 1, v: 'b' }]

normalizeRecord

Normalizes one flat record: trims strings, strips invisible characters, optionally collapses whitespace, maps empty strings to null, normalizes keys, and converts listed fields.

function normalizeRecord(record: Readonly<Record<string, unknown>>, rules?: NormalizeRecordRules): Record<string, unknown>;

Example:

normalizeRecord({ ' Age ': ' 42 ', Name: ' Ada L ' }, { lowercaseKeys: true, collapseWhitespace: true, toNumber: ['age'] })
// { age: 42, name: 'Ada L' }

removeEmpty

Removes empty strings, empty arrays and empty plain objects (and, by default, nullish values). With deep, containers that become empty after cleaning are removed too.

function removeEmpty<T>(value: T, options?: RemoveEmptyOptions): T;

Example:

removeEmpty({ a: '', b: [], c: {}, d: 0 }) // => { d: 0 }

removeNullish

Removes null and undefined properties and array items (top level unless deep).

function removeNullish<T>(value: T, options?: DeepOptions): T;

Example:

removeNullish({ a: 1, b: null, c: undefined }) // => { a: 1 }

trimStrings

Trims every string; with deep (default true) also inside nested plain objects and arrays.

function trimStrings<T>(value: T, options?: DeepOptions): T;

Example:

trimStrings({ name: ' Ada ', tags: [' x '] }) // => { name: 'Ada', tags: ['x'] }

CleanRecordsOptions

interface CleanRecordsOptions<T> {
readonly normalize?: NormalizeRecordRules | false;
readonly dropEmpty?: boolean;
readonly dedupeBy?: (record: Record<string, unknown>, index: number) => unknown;
readonly keep?: 'first' | 'last';
readonly filter?: (record: Record<string, unknown>) => boolean;
readonly map?: (record: Record<string, unknown>) => T;
}

CoerceOptions

interface CoerceOptions {
readonly onInvalid?: 'null' | 'keep' | 'omit';
readonly strip?: boolean;
}

DedupeOptions

interface DedupeOptions {
readonly keep?: 'first' | 'last';
}

DeepOptions

interface DeepOptions {
readonly deep?: boolean;
readonly maxDepth?: number;
}

NormalizeRecordRules

interface NormalizeRecordRules {
readonly trim?: boolean;
readonly collapseWhitespace?: boolean;
readonly stripInvisible?: boolean;
readonly emptyToNull?: boolean;
readonly lowercaseKeys?: boolean;
readonly trimKeys?: boolean;
readonly toNumber?: readonly string[];
readonly toBoolean?: readonly string[];
readonly lowercase?: readonly string[];
}

RemoveEmptyOptions

interface RemoveEmptyOptions extends DeepOptions {
readonly whitespace?: boolean;
readonly nullish?: boolean;
}

CoerceType

Target types for {@link coerceRecordTypes}.

type CoerceType = 'string' | 'number' | 'integer' | 'boolean' | 'date' | 'json';

collapseWhitespace

Trims and collapses runs of whitespace (any Unicode \s) to a single space.

function collapseWhitespace(text: string): string;

Example:

collapseWhitespace(' a \n b ') // => 'a b'

normalizeEmail

Trims and lowercases the domain of an e-mail address. Returns undefined when the input has no single @ separating non-empty parts or exceeds 254 characters.

function normalizeEmail(email: string, options?: NormalizeEmailOptions): string | undefined;

Example:

normalizeEmail(' John.Doe@Example.COM ') // => 'John.Doe@example.com'

normalizePhone

Normalizes a phone number to E.164 (+&lt;digits>, 8-15 digits) by digits only; no carrier or numbering-plan logic. Returns undefined when the result cannot be E.164.

function normalizePhone(input: string, options?: NormalizePhoneOptions): string | undefined;

Example:

normalizePhone('(020) 7946 0958', { defaultCountryCode: '44' }) // => '+442079460958'

normalizeUnicode

Unicode normalization (NFC default). Use NFKC to fold compatibility forms (full-width letters, ligatures) before comparisons or search.

function normalizeUnicode(text: string, form?: 'NFC' | 'NFD' | 'NFKC' | 'NFKD'): string;

Example:

normalizeUnicode('full', 'NFKC') // => 'full'

safeFileName

Makes a string safe to use as a single file name on Windows, macOS and Linux: strips path separators, control and invisible characters, reserved device names (CON, NUL, COM1…), trailing dots/spaces, and leading dots; truncates to maxLength UTF-8 bytes keeping the extension.

function safeFileName(name: string, options?: SafeFileNameOptions): string;

Example:

safeFileName('../../etc/passwd') // => 'etc_passwd'

Example:

safeFileName('CON.txt') // => '_CON.txt'

sanitizeSearchQuery

Cleans free-text search input: strips control and invisible characters, folds compatibility forms, collapses whitespace, and limits length. Not an injection defense: always use parameterized queries.

function sanitizeSearchQuery(query: string, options?: SanitizeSearchQueryOptions): string;

Example:

sanitizeSearchQuery(' hello world ') // => 'hello world'

stripControl

Removes C0/C1 control characters. Tab, LF and CR are kept unless keepWhitespace is false.

function stripControl(text: string, options?: {
readonly keepWhitespace?: boolean;
}): string;

Example:

stripControl('a\u0000b') // => 'ab'

stripInvisible

Removes zero-width characters and bidi control characters (U+202A-202E, U+2066-2069), which can hide or reorder text (Trojan Source). Variation selectors are kept (emoji).

function stripInvisible(text: string, options?: StripInvisibleOptions): string;

Example:

stripInvisible('ad' + String.fromCharCode(0x200b) + 'min') // => 'admin'

truncateForLog

Serializes any value for logging and truncates it to maxLength code units (default 1000), appending ...[+N] with the number of characters removed. Handles cycles and BigInt.

function truncateForLog(value: unknown, maxLength?: number): string;

Example:

truncateForLog('x'.repeat(2000), 10) // => 'xx…[+1998]'

NormalizeEmailOptions

interface NormalizeEmailOptions {
readonly lowercaseLocal?: boolean;
readonly gmail?: boolean;
readonly stripPlusTag?: boolean;
}

NormalizePhoneOptions

interface NormalizePhoneOptions {
readonly defaultCountryCode?: string;
readonly stripTrunkZero?: boolean;
}

SafeFileNameOptions

interface SafeFileNameOptions {
readonly maxLength?: number;
readonly replacement?: string;
readonly allowLeadingDot?: boolean;
readonly fallback?: string;
}

SanitizeSearchQueryOptions

interface SanitizeSearchQueryOptions {
readonly maxLength?: number;
readonly fold?: boolean;
}

StripInvisibleOptions

interface StripInvisibleOptions {
readonly keepJoiners?: boolean;
}