Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,17 +130,20 @@
## API

### `safeTag(value: unknown): string` (default export)

- **Returns:** A string in the form `[object Type]`.
- **Exception handling:** Guaranteed not to throw. For hostile objects (like revoked proxies), returns `"[object Object]"`.
- **Behavior:** Returns the tag as the engine sees it. Respects `Symbol.toStringTag` masks and never mutates the input.

### `unmaskTag(value: unknown): string`

Advanced API that attempts to reveal the underlying tag by temporarily mutating the object's own `Symbol.toStringTag` descriptor.

- **Risk:** May cause V8 to de-optimize the object (hidden class changes) due to descriptor mutation.
- **Side effects:** Temporarily mutates `Symbol.toStringTag` on the object during the read, but is designed to restore the original descriptor and **never throws**. Falls back to `safeTag(value)` if unmasking fails.

- **Returns:** A string in the form `[object Type]`.
- **Performance:** High. Does not mutate inputs and avoids V8 de-optimizations.
- **Exception handling:** Guaranteed not to throw. For hostile objects (like revoked proxies), returns `"[object Object]"`.
- **Behavior:** Returns the tag as the engine sees it. Respects `Symbol.toStringTag` masks. **Recommended for 99% of use cases.**

### `unmaskTag(value: unknown): string`

Advanced API that attempts to reveal the underlying innate tag, even if it has been spoofed using an own `Symbol.toStringTag`.

- **Risk:** May cause V8 to de-optimize the object (hidden class changes) due to temporary descriptor mutation.
- **Usage:** **Should be used only when you need to bypass spoofed tags (e.g., in security-sensitive code).** Not needed for normal type checks.
- **Innate detection:** Includes a fast, non-mutating path for common built-ins (Array, Date, RegExp, Map, Set, Promise, Function, Error) to avoid de-optimization when possible.
- **Side effects:** Temporarily mutates `Symbol.toStringTag` on the object during the read if the innate fast-path is not available. Designed to restore the original descriptor and **never throws**. Falls back to `safeTag(value)` if unmasking fails.

### Performance variants (`safe-tag/fast`)

Expand Down
48 changes: 22 additions & 26 deletions bench/index.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
// Benchmark for safe-tag
const { performance } = require("perf_hooks");
const { default: safeTag } = require("../dist/index.js");
const { default: safeTag, unmaskTag } = require("../dist/index.js");
const { fastTag, ultraFastTag, cachedTag } = require("../dist/fast.js");

const ITERATIONS = 100000;

console.log("=== safe-tag Benchmarks ===");
console.log(`Running ${ITERATIONS} iterations per test...\n`);

// Test data
const testCases = [
{ name: "null", value: null },
{ name: "undefined", value: undefined },
Expand All @@ -19,36 +19,32 @@
{ name: "Date", value: new Date() },
];

// Native Object.prototype.toString for comparison
const nativeToString = Object.prototype.toString;

console.log("--- Baseline: Object.prototype.toString ---");
for (const testCase of testCases) {
const start = performance.now();
for (let i = 0; i < ITERATIONS; i++) {
try {
nativeToString.call(testCase.value);
} catch (e) {
// Baseline may throw on hostile objects
function runBench(name, fn, cases) {
console.log(`--- ${name} ---`);
for (const testCase of cases) {
const start = performance.now();
for (let i = 0; i < ITERATIONS; i++) {
try {
fn(testCase.value);
} catch (e) {}

Check failure on line 31 in bench/index.js

View workflow job for this annotation

GitHub Actions / lint

Empty block statement
}
const end = performance.now();
const opsPerSec = ((ITERATIONS / (end - start)) * 1000).toFixed(0);
console.log(`${testCase.name.padEnd(25)} ${opsPerSec.padStart(12)} ops/sec`);
}
const end = performance.now();
const opsPerSec = ((ITERATIONS / (end - start)) * 1000).toFixed(0);
console.log(`${testCase.name.padEnd(20)} ${opsPerSec.padStart(10)} ops/sec`);
console.log("");
}

console.log("\n--- safeTag (safe wrapper) ---");
for (const testCase of testCases) {
const start = performance.now();
for (let i = 0; i < ITERATIONS; i++) {
safeTag(testCase.value);
}
const end = performance.now();
const opsPerSec = ((ITERATIONS / (end - start)) * 1000).toFixed(0);
console.log(`${testCase.name.padEnd(20)} ${opsPerSec.padStart(10)} ops/sec`);
}
runBench("Baseline: Object.prototype.toString", (v) => nativeToString.call(v), testCases);
runBench("safeTag (safe wrapper)", safeTag, testCases);
runBench("unmaskTag (advanced revelation)", unmaskTag, testCases);
runBench("fastTag (minimal wrapper)", fastTag, testCases);
runBench("ultraFastTag (direct call)", ultraFastTag, testCases);
runBench("cachedTag (WeakMap cache)", cachedTag, testCases.filter(c => c.value && typeof c.value === 'object'));

console.log("\n--- Hostile Object Tests ---");
console.log("--- Hostile Object Tests (safeTag) ---");
const revokedProxy = (() => {
const { proxy, revoke } = Proxy.revocable({}, {});
revoke();
Expand All @@ -75,7 +71,7 @@
}
const end = performance.now();
const opsPerSec = ((ITERATIONS / (end - start)) * 1000).toFixed(0);
console.log(`${testCase.name.padEnd(20)} ${opsPerSec.padStart(10)} ops/sec`);
console.log(`${testCase.name.padEnd(25)} ${opsPerSec.padStart(12)} ops/sec`);
}

console.log("\n=== Benchmark Complete ===");
4 changes: 2 additions & 2 deletions src/fast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ export function fastTag(value: unknown): string {
* @param value - Any value to tag.
* @returns The native "[object Type]" tag.
*/
export function ultraFastTag(value: any): string {
return nativeToString.call(value);
export function ultraFastTag(value: unknown): string {
return nativeToString.call(value as object);
}

const tagCache = new WeakMap<object, string>();
Expand Down
36 changes: 33 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,33 @@ export default function safeTag(value: unknown): string {
}
}

/**
* Detects the innate tag of common built-in objects without mutation.
* This is a fast, safe path that avoids V8 de-optimization.
*/
function getInnateTag(value: object): string | undefined {
if (Array.isArray(value)) return "[object Array]";
if (value instanceof Date) return "[object Date]";
if (value instanceof RegExp) return "[object RegExp]";
if (value instanceof Map) return "[object Map]";
if (value instanceof Set) return "[object Set]";
if (value instanceof Promise) return "[object Promise]";
if (typeof value === "function") return "[object Function]";
if (value instanceof Error) return "[object Error]";
return undefined;
}

/**
* Explicitly attempts to reveal the underlying tag by temporarily disabling an
* own Symbol.toStringTag, if present and configurable.
*
* This operation mutates the target's property descriptor but guarantees that it
* will never throw. If unmasking fails for any reason, it falls back to
* safeTag(value).
* PERFORMANCE WARNING: This operation mutates the target's property descriptor
* which may cause V8 hidden class de-optimizations. Use only when you must
* bypass spoofed tags in security-sensitive contexts. For normal type checks,
* use safeTag() or check innate types directly.
*
* This operation is guaranteed that it will never throw. If unmasking fails
* for any reason, it falls back to safeTag(value).
*/
export function unmaskTag(value: unknown): string {
if (value === null || (typeof value !== "object" && typeof value !== "function")) {
Expand All @@ -46,6 +66,16 @@ export function unmaskTag(value: unknown): string {
return safeTag(value);
}

// Try non-mutating path first for common built-ins.
// We wrap this in a try/catch because built-in checks like Array.isArray
// can throw on revoked proxies in some engines/environments.
try {
const innate = getInnateTag(value as object);
if (innate) return innate;
} catch {
// Fall through to standard unmasking logic if innate check fails.
}

try {
const descriptor = getOwnDescriptor(value, symToStringTag);

Expand Down
Loading