<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Muhammad Arslan</title>
    <description>The latest articles on DEV Community by Muhammad Arslan (@marslanmustafa).</description>
    <link>https://dev.to/marslanmustafa</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1889950%2Fcd2e77bd-aa55-4402-8d1a-678f619c0861.png</url>
      <title>DEV Community: Muhammad Arslan</title>
      <link>https://dev.to/marslanmustafa</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/marslanmustafa"/>
    <language>en</language>
    <item>
      <title>The "Transfer Counted as an Expense" Bug That Breaks Almost Every Budgeting App</title>
      <dc:creator>Muhammad Arslan</dc:creator>
      <pubDate>Sun, 09 Aug 2026 18:22:15 +0000</pubDate>
      <link>https://dev.to/marslanmustafa/the-transfer-counted-as-an-expense-bug-that-breaks-almost-every-budgeting-app-524p</link>
      <guid>https://dev.to/marslanmustafa/the-transfer-counted-as-an-expense-bug-that-breaks-almost-every-budgeting-app-524p</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F882hnyrd9src3myx1mc2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F882hnyrd9src3myx1mc2.png" alt=" " width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Move ₨15,000 from your bank account to a digital wallet. Nothing was&lt;br&gt;
earned. Nothing was spent. Your net worth is identical to a second ago.&lt;/p&gt;

&lt;p&gt;Open almost any budgeting app afterward and it'll cheerfully tell you that&lt;br&gt;
you just "spent" ₨15,000.&lt;/p&gt;

&lt;p&gt;I hit this constantly while building &lt;strong&gt;&lt;a href="https://danapani.app" rel="noopener noreferrer"&gt;Danapani&lt;/a&gt;&lt;/strong&gt;,&lt;br&gt;
a multi-ledger personal finance app, and it turned out to be a genuinely&lt;br&gt;
interesting data-modeling problem — not just a UI bug. Here's how the&lt;br&gt;
transaction model actually works under the hood.&lt;/p&gt;
&lt;h2&gt;
  
  
  The naive model (and why it breaks)
&lt;/h2&gt;

&lt;p&gt;The obvious first pass at a finance schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model Transaction {
  id        String   @id @default(uuid())
  accountId String
  type      TransactionType // INCOME | EXPENSE
  amount    Decimal
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every transaction either adds to a balance or subtracts from it. Simple —&lt;br&gt;
until a user has more than one account. The moment money moves &lt;em&gt;between&lt;/em&gt;&lt;br&gt;
their own accounts, this model has no way to express "this isn't real&lt;br&gt;
income or a real expense, it's the same money in a different place." So&lt;br&gt;
it gets recorded as an &lt;code&gt;EXPENSE&lt;/code&gt; on the source account, and your monthly&lt;br&gt;
spending chart is now wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix: a &lt;code&gt;TRANSFER&lt;/code&gt; type with two linked legs
&lt;/h2&gt;

&lt;p&gt;Instead of forcing a transfer through the income/expense lens, Danapani&lt;br&gt;
treats it as a first-class type with two rows sharing a group ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model Transaction {
  id                  String   @id @default(uuid())
  userId              String
  accountId           String
  transferToId        String?
  type                TransactionType   // INCOME | EXPENSE | TRANSFER
  amount              Decimal
  transferGroupId     String?
  relatedTransactionId String?
  // ...
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A single transfer becomes &lt;strong&gt;two&lt;/strong&gt; &lt;code&gt;Transaction&lt;/code&gt; rows, created atomically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUniqueOrThrow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sourceId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Insufficient funds&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;groupId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outbound&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TRANSFER&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;transferGroupId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;groupId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inbound&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;destinationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TRANSFER&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;transferGroupId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;relatedTransactionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;outbound&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sourceId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decrement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;destinationId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two consequences fall out of this for free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reporting is correct by construction.&lt;/strong&gt; Income/expense charts simply
filter &lt;code&gt;WHERE type != 'TRANSFER'&lt;/code&gt; — no special-casing needed downstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deletes and edits stay consistent.&lt;/strong&gt; Delete one leg, and the handler
looks up &lt;code&gt;relatedTransactionId&lt;/code&gt; and reverts both account balances
together, instead of leaving an orphaned half-transfer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole &lt;code&gt;prisma.$transaction()&lt;/code&gt; block matters here — without it, a&lt;br&gt;
crash between the two balance updates leaves one account short and the&lt;br&gt;
other over, and now the user's money has silently duplicated or&lt;br&gt;
vanished. Wrapping it means either both legs commit or neither does.&lt;/p&gt;
&lt;h2&gt;
  
  
  Isolating a user's &lt;em&gt;own&lt;/em&gt; books from each other
&lt;/h2&gt;

&lt;p&gt;The second problem: a single person can have very different reasons to&lt;br&gt;
track money — personal spending, freelance client income, a small side&lt;br&gt;
business — and mixing them in one view makes monthly reports meaningless.&lt;/p&gt;

&lt;p&gt;Danapani solves this with a &lt;code&gt;Ledger&lt;/code&gt; model that transactions optionally&lt;br&gt;
tag into:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model Ledger {
  id       String @id @default(uuid())
  userId   String
  name     String // "Personal", "Freelance USD", "Business"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A transfer between a Meezan-style bank account and a wallet, tagged to&lt;br&gt;
the same ledger, nets to zero on that ledger's report. Money moved&lt;br&gt;
&lt;em&gt;between&lt;/em&gt; ledgers (say, paying yourself from the business ledger into&lt;br&gt;
personal) is still just a transfer — same mechanism, just crossing a&lt;br&gt;
ledger boundary instead of an account boundary.&lt;/p&gt;
&lt;h2&gt;
  
  
  Multi-tenant isolation with Postgres Row-Level Security
&lt;/h2&gt;

&lt;p&gt;None of this matters if user A can query user B's transactions. Rather&lt;br&gt;
than repeating &lt;code&gt;WHERE userId = ?&lt;/code&gt; in every single query by hand (and&lt;br&gt;
inevitably forgetting it somewhere), the isolation is enforced at the&lt;br&gt;
database layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"Transaction"&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;user_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="nv"&gt;"Transaction"&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_user_id'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request sets &lt;code&gt;app.current_user_id&lt;/code&gt; from the verified JWT before&lt;br&gt;
running any query. Even a bug that forgets a &lt;code&gt;WHERE&lt;/code&gt; clause in&lt;br&gt;
application code can't leak another user's rows — Postgres itself&lt;br&gt;
refuses to return them. For a finance app specifically, I wanted that&lt;br&gt;
guarantee to not depend on every future engineer (including future me)&lt;br&gt;
remembering to scope every query correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack, if you're curious
&lt;/h2&gt;

&lt;p&gt;Next.js 15 (App Router) · TypeScript · Prisma · PostgreSQL · Redux&lt;br&gt;
Toolkit + RTK Query on the frontend, hitting the same&lt;br&gt;
&lt;code&gt;prisma.$transaction()&lt;/code&gt; pattern above for every mutation that touches&lt;br&gt;
more than one row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it / poke holes in it
&lt;/h2&gt;

&lt;p&gt;Danapani is live and free at &lt;strong&gt;&lt;a href="https://danapani.app" rel="noopener noreferrer"&gt;danapani.app&lt;/a&gt;&lt;/strong&gt; if&lt;br&gt;
you want to see the transfer/ledger model in action. I'd genuinely like&lt;br&gt;
feedback from other people who've built financial or multi-tenant&lt;br&gt;
systems — particularly if you've solved the double-entry problem&lt;br&gt;
differently, or found a hole in the RLS approach above.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>management</category>
      <category>buildinpublic</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Why Your Profanity Filter Fails Against Unicode (And How to Fix It)</title>
      <dc:creator>Muhammad Arslan</dc:creator>
      <pubDate>Tue, 24 Feb 2026 18:37:46 +0000</pubDate>
      <link>https://dev.to/marslanmustafa/why-your-profanity-filter-fails-against-unicode-and-how-to-fix-it-40fd</link>
      <guid>https://dev.to/marslanmustafa/why-your-profanity-filter-fails-against-unicode-and-how-to-fix-it-40fd</guid>
      <description>&lt;p&gt;&lt;strong&gt;Most profanity filters only check raw input.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That’s the problem.&lt;/p&gt;

&lt;p&gt;You can block &lt;code&gt;fuck&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But what about:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fu\u0441k&lt;/code&gt; (Cyrillic “с” instead of Latin “c”)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ｆｕｃｋ&lt;/code&gt; (fullwidth Unicode characters)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;f.u.c.k&lt;/code&gt; (separator bypass)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Fr33 m0ney&lt;/code&gt; (leet-speak)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fuuuuck&lt;/code&gt; (character stretching)&lt;/p&gt;

&lt;p&gt;They all bypass typical word-list filters.&lt;/p&gt;

&lt;p&gt;The issue isn’t your regex.&lt;br&gt;
It’s the &lt;strong&gt;order of operations&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Normalize First. Validate Second.
&lt;/h2&gt;

&lt;p&gt;Before checking profanity or spam, input should be normalized:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Unicode NFKC normalization&lt;/li&gt;
&lt;li&gt;Zero-width character removal&lt;/li&gt;
&lt;li&gt;Separator stripping&lt;/li&gt;
&lt;li&gt;Homoglyph mapping&lt;/li&gt;
&lt;li&gt;Leet-speak normalization&lt;/li&gt;
&lt;li&gt;Repetition reduction&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After normalization, all evasions collapse into a canonical form.&lt;br&gt;
Then your profanity/spam logic actually works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I Built&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I created &lt;strong&gt;&lt;em&gt;&lt;a class="mentioned-user" href="https://dev.to/marslanmustafa"&gt;@marslanmustafa&lt;/a&gt;/input-shield — a zero-dependency TypeScript validation package&lt;/em&gt;&lt;/strong&gt; that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Detects Unicode homoglyph attacks&lt;/li&gt;
&lt;li&gt;Catches leet-based spam&lt;/li&gt;
&lt;li&gt;Blocks stretched profanity&lt;/li&gt;
&lt;li&gt;Detects gibberish (e.g. asdfghjkl)&lt;/li&gt;
&lt;li&gt;Supports Zod integration&lt;/li&gt;
&lt;li&gt;Validates HTML email content safely&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import { createValidator } from '@marslanmustafa/input-shield';

const validator = createValidator()
  .field('Message')
  .min(2).max(500)
  .noProfanity()
  .noSpam()
  .noGibberish();

validator.validate('fu\u0441k'); 
// → blocked
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why This Matters&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Unicode homoglyph attacks are not edge cases.&lt;br&gt;
They’re easy, invisible, and widely ignored.&lt;/p&gt;

&lt;p&gt;If you're validating user input in production, normalization isn’t optional. It’s required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Links:&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;a href="https://github.com/marslanmustafa/input-shield" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/@marslanmustafa/input-shield" rel="noopener noreferrer"&gt;npm&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>security</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
