piiforge
v0.1.2
Published
Schema declaration and masking library with static type inference.
Maintainers
Readme
piiforge
Declarative, type-safe JSON masking for TypeScript and JavaScript. Per-field strategies, composable schemas, static type inference.
- Per-field masking strategies - email becomes
j*****[email protected], phone becomes********7890, password becomes[REDACTED] - Composable schemas - describe a whole object once, reuse it anywhere
- Static type inference -
Infer<typeof schema>produces the masked shape - Immutable - every
apply()returns a new object, inputs are never mutated - Type-safe inputs - string maskers reject non-string inputs at compile time; runtime fallback redacts safely
- Strict mode - opt-in dropping of keys not declared in the schema
- Zero runtime dependencies, ESM + CJS, ships with TypeScript types
- Tiny surface - one
xnamespace, onemasknamespace, oneInfertype
import { x, mask } from 'piiforge';
// Mask one value, no schema.
mask.email('[email protected]'); // 'j*****[email protected]'
mask.keep('081234567890', { end: 4 }); // '********7890'
mask.keep('081234567890', { start: 2, end: 2 }); // '08********90'
mask.redact('hunter2'); // '[REDACTED]'
mask.redact('hunter2', '****'); // '****'
mask.truncate('long text here...', 8); // 'long tex…'
// Declarative schema for a whole object.
const userMask = x.object({
id: x.passthrough(),
email: x.email(),
phone: x.keep({ end: 4 }),
password: x.redact(),
fullName: x.keep({ start: 1, end: 1 }),
});
userMask.apply({
id: 42,
email: '[email protected]',
phone: '081234567890',
password: 'hunter2',
fullName: 'JohnDoe',
});
// {
// id: 42,
// email: 'j*****[email protected]',
// phone: '********7890',
// password: '[REDACTED]',
// fullName: 'J*****e',
// }Install
npm install piiforge
# or
pnpm add piiforge
# or
yarn add piiforge
# or
bun add piiforgeBoth ESM and CommonJS are shipped:
import { x, mask } from 'piiforge'; // ESM / TypeScript
const { x, mask } = require('piiforge'); // CommonJSRequires Node 22+. Zero runtime dependencies. DevDeps (tsup, vitest, typescript) are not installed for consumers.
Schema API
x.object and x.array compose into arbitrarily nested schemas. Every field declares how its value is masked.
import { x } from 'piiforge';
const userMask = x.object({
id: x.passthrough(),
email: x.email(),
phone: x.keep({ end: 4 }),
password: x.redact(),
fullName: x.keep({ start: 1, end: 1 }),
address: x.object({
street: x.redact(),
city: x.passthrough(),
zip: x.keep({ end: 2 }),
}),
tags: x.passthrough(),
posts: x.array(x.object({
title: x.passthrough(),
secretDraft: x.redact(),
})),
});
const masked = userMask.apply(userData);Built-in maskers
| Function | Behavior | Example |
| ------------------------------ | --------------------------------------------------------------------- | ------------------------------------------------ |
| x.passthrough() | Return value unchanged | 'abc' -> 'abc' |
| x.redact(replacement?) | Replace entire value with placeholder | 'abc' -> '[REDACTED]' |
| x.keep({ start?, end?, maskLength? }) | Keep N chars at start/end, mask the rest | '1234567890' {end: 4} -> '******7890' |
| x.email({ start?, end?, maskLength? }) | Mask local part of an email, keep first & last char by default | '[email protected]' -> 'j*****[email protected]' |
| x.truncate(maxLen, suffix?) | Truncate string to max length, append suffix (default …) | 'long text' 5 -> 'long …' |
| x.creditCard({ keepLast? }) | Mask digits in a card number, preserving separators and the last N digits | '4111-2222-3333-4444' -> '****-****-****-4444' |
| x.initials({ separator?, trailing?, uppercase? }) | Reduce a name to its initials | 'John Doe' -> 'J.D.' |
| x.fn(fn) | Run a custom transform | (v) => v.toUpperCase() |
| x.object(shape, opts?) | Nested object masking (supports { strict: true }) | See above |
| x.array(itemSchema) | Apply itemSchema to every element | See above |
Every string masker accepts a { char } option to override the default *:
x.keep({ end: 4 }, { char: '•' }).apply('081234567890');
// '••••••••7890'maskLength pins the number of mask characters regardless of input length - useful for hiding how long the original value was:
x.email({ start: 1, end: 1, maskLength: 3 }).apply('[email protected]');
// 'j***[email protected]'TypeScript
import { x, type Infer } from 'piiforge';
const userMask = x.object({
email: x.email(),
phone: x.keep({ end: 4 }),
age: x.passthrough<number>(),
});
type User = Infer<typeof userMask>;
// { email: string; phone: string; age: number }The inferred type matches the masked output, so it composes with the rest of your type system without as casts.
Recipes
Custom masking with x.fn
const mask = x.object({
birthYear: x.fn<number>((v) => Math.floor(v / 10) * 10), // 1987 -> 1980
reversed: x.fn<string>((v) => v.split('').reverse().join('')),
});Strict mode - drop unknown keys
const m = x.object({ email: x.email() }, { strict: true });
m.apply({ email: '[email protected]', password: 'secret' });
// { email: '[email protected]' } - password dropped, not just maskedArrays of objects
const postsMask = x.array(
x.object({ title: x.passthrough(), secret: x.redact() })
);Rules
null and undefined pass through unchanged on every masker. Masking a non-existent value should not invent one.
x.email().apply(null); // null
x.redact().apply(undefined); // undefinedType mismatches fall back to redact(). If a string masker receives a non-string at runtime, it returns [REDACTED] rather than throwing. TypeScript also rejects the call at compile time.
// @ts-expect-error - keep expects a string
x.keep({ end: 4 }).apply(12345); // '[REDACTED]'Schema keys missing from the input materialize as undefined. Every key declared in x.object(...) appears in the output, even if the input didn't have it.
x.object({ email: x.email() }).apply({}); // { email: undefined }Non-strict mode (default) copies unknown keys through; strict mode drops them.
const lax = x.object({ email: x.email() });
lax.apply({ email: '[email protected]', extra: 'kept' });
// { email: '[email protected]', extra: 'kept' }
const strict = x.object({ email: x.email() }, { strict: true });
strict.apply({ email: '[email protected]', extra: 'dropped' });
// { email: '[email protected]' }Apply is pure. Inputs are never mutated. The output is always a new top-level object or array. Note that fields handled by x.passthrough() reuse the input reference - piiforge does not deep-clone.
Invalid emails (no @) fall back to redact(). Multiple @ characters split on the last one ('a@[email protected]' -> local 'a@b', domain '@x.com').
keep and email return the value unchanged if there's nothing left to mask. When start + end >= value.length, masking would reveal that there was something to hide. So nothing is changed.
License
MIT
