DEV Community

Cover image for How to handle input masking in TypeScript with zero dependencies
Thiago Lagden
Thiago Lagden

Posted on

How to handle input masking in TypeScript with zero dependencies

Sooner or later, every form needs a masked input: a phone number, a CPF, a currency field, a credit card. The instinct is to reach for npm and install something battle-tested. The problem is that "battle-tested" often means "battle-sized" — a masking library pulling in a parser, a runtime, and a handful of transitive dependencies just to reformat a string as the user types.

The actual problem is small

Input masking, at its core, is a straightforward transformation: take the raw characters a user types, walk them against a pattern, and reinsert literals (parentheses, dashes, slashes) at the right spots. There's no need for a state machine framework or a virtual DOM diffing engine. It's string manipulation triggered by a DOM event.

Yet a lot of the popular options treat it as a much bigger problem than it is. Some are written for a specific framework, others ship internationalization tables you'll never touch, others are frozen at whatever @types/* package the community last bothered to update. Every one of those is a dependency you now have to audit, patch, and carry through every npm audit for the life of the project — for a feature that's fundamentally a replace loop.

What a minimal solution actually needs

A masking utility only needs to do a few things well:

  • Accept a pattern like 999.999.999-99 or (99) 9-9999-9999, where tokens mean digit, letter, or alphanumeric.
  • Apply it to a string, pure — no DOM required, so it's testable and usable on the server too.
  • Bind to an <input> and re-apply on typing, without fighting the browser's own event loop.
  • Handle the edge cases that actually come up in real forms: switching from CPF to CNPJ mid-typing, applying the mask on blur instead of on every keystroke, reading the pattern from a data-mask attribute so designers can change it without touching JS.

That's the whole surface area. Nothing about it requires a bundle measured in tens of kilobytes.

Zero dependencies, real TypeScript support

This is exactly the gap @tadashi/mask fills. It's a single-file, dependency-free ESM module — about 1 kB minified and gzipped — that does the masking transformation as a pure static function and, optionally, binds itself to an input element:

import Mask from '@tadashi/mask'

const mask = new Mask(input, { mask: 'SSS-9999', init: true })
Enter fullscreen mode Exit fullscreen mode

Need the raw transformation without touching the DOM at all — say, to format a value before saving it, or in a Node.js script?

import Mask from '@tadashi/mask'

Mask.core('ABC12', 'SSS-9999') // => 'ABC-12'
Enter fullscreen mode Exit fullscreen mode

TypeScript users aren't an afterthought either: types are generated straight from JSDoc annotations and shipped with the package, so tsc and your editor get full autocomplete and type-checking with no separate @types package to install, version, or fall out of sync.

You can see it in action, live, in this CodePen example — phone, CPF/CNPJ and other masks applied in real time as you type.

The takeaway

Before installing a masking library, it's worth asking what the problem actually requires. Most of the time it's pattern matching over a string, plus a thin event listener — not a framework. Reaching for a zero-dependency, purpose-built tool like @tadashi/mask keeps that promise: the bundle stays tiny, the dependency tree stays empty, and TypeScript support comes built in rather than bolted on.

Top comments (0)