Skip to content

fix(type): preserve escaped backslash in regex string literals#1634

Open
spokodev wants to merge 1 commit into
arktypeio:mainfrom
spokodev:fix/regex-literal-escaped-backslash
Open

fix(type): preserve escaped backslash in regex string literals#1634
spokodev wants to merge 1 commit into
arktypeio:mainfrom
spokodev:fix/regex-literal-escaped-backslash

Conversation

@spokodev

@spokodev spokodev commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

A regex written as a string literal loses escaped backslashes, so a pattern that should match a literal backslash is compiled as a metacharacter class and validates the wrong values. The string-literal form and the equivalent RegExp-instance form disagree, even though the docs treat them as equivalent.

Repro

A regex matching a literal backslash followed by d is /^\\d$/ as a RegExp. Written as a string literal the backslashes double again, so it becomes "/^\\\\d$/".

import { type } from "arktype"

const FromInstance = type(/^\\d$/)     // literal backslash + d
const FromString = type("/^\\\\d$/")   // same intent, written as a string

FromInstance.allows("\\d")  // true   ("\\d" is the two character string: backslash, d)
FromString.allows("\\d")    // actual false, expected true
FromString.allows("5")      // actual true,  expected false

FromString is compiled as the digit class \d instead of a literal backslash plus d, so it rejects the value it should accept and accepts the value it should reject. FromInstance, which does not go through the string scanner, is correct, so the two forms diverge for the same intended pattern.

Root cause

Both forms end in new RegExp(source), but the string-literal path reads the source between the slashes with Scanner.shiftUntilEscapable, which collapses every \\ into a single \. That rule is correct for a JS string literal, where \\ denotes one backslash. It is wrong for a regex source: there \\ is a literal backslash while \d, \b, \w are metacharacters, so collapsing turns a literal backslash into a metacharacter and changes what the pattern accepts.

Fix

Add an optional escapeEscape argument to shiftUntilEscapable. For regex tokens the parser emits \\ instead of collapsing it, keeping the pattern source equal to the RegExp-instance form. The change is applied in both the runtime parser and its type-level twin, so runtime validation and inferred types stay in agreement. Quoted-string and date literals are unchanged and still collapse \\; escaped terminators (\/) and single-backslash classes (\d, \w, \t) are unaffected.

Authority

A regex literal's content is a regex source, not a JS string, so \\ in it means one literal backslash. arktype's own RegExp-instance path (type(/^\\d$/)) already preserves this, and the docs treat type("/regex/") as equivalent to the corresponding RegExp, so the string form must produce the same pattern. The string scanner instead applied JS-string un-escaping to the regex source, which is the divergence this fixes.

Tests

Added cases in ark/type/__tests__/regex.test.ts covering string-literal vs RegExp-instance parity, literal-backslash acceptance, a lone escaped backslash, and regression guards for single-backslash classes and escaped terminators. Verified red on the current base (the parity and backslash assertions fail without the parser change) and green with it. Full package suite passes (1774 passing; the 2 failing snapPopulation cases are pre-existing under --skipTypes and unrelated to this change).

A regex written as a string literal was parsed with the string-escape
rule, which collapses `\\` into a single `\`. That is correct for a JS
string but corrupts a regex source, where `\\` is a literal backslash
and `\d`/`\b`/`\w` are metacharacters. So `type("/^\\\\d$/")` silently
became the digit-class regex `/^\d$/`, accepting "5" and rejecting "\d",
disagreeing with the equivalent RegExp-instance form.

Add an optional `escapeEscape` argument to `shiftUntilEscapable` and pass
a backslash for regex tokens in both the runtime and type-level parsers,
so a doubled backslash is preserved in the pattern source. Quoted-string
and date literals keep collapsing `\\` as before; escaped terminators
`\/` and single-backslash classes `\d`/`\w`/`\t` are unaffected.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — the fix ensures /regex/ string literals preserve escaped backslashes so the runtime and type-level parsers agree with the RegExp-instance form.

  • Add escapeEscape parameter to Scanner.shiftUntilEscapable to opt into preserving \\ as a literal backslash.
  • Wire regex literals (/ and x/) in both runtime and type-level parseEnclosed to pass Backslash.
  • Leave quoted-string and date literal escaping behavior unchanged.
  • Add regression tests for escaped-backslash parity, literal backslash, lone \\, single-backslash classes, and escaped terminators.

Pullfrog  | View workflow run | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: To do

Development

Successfully merging this pull request may close these issues.

1 participant