Maskito is a new collection of libraries for text field masking

Nikita Barsukov
IT’s Tinkoff
Published in
11 min readJun 22, 2023

--

We are happy to announce that we have released our project Maskito to Open Source. The first stable major version is now available. Maskito is a collection of libraries to simplify the process of masking text fields with a convenient and flexible public API.

Maskito comes with several libraries. The main one is a zero dependency Typescript package. It is all you need to create a mask for your web application. There is also an optional package with configurable, ready-to-use masks. And of course there are libraries for modern web frameworks: you can use Maskito in React, Angular or Vue. Let’s dive into the details.

A bit of theory

The terms “mask”, “input or text field masking”, and other similar words are mentioned many times in the article. Let’s discuss the meaning of this term for the Web.

If it had a formal definition, it would sound something like this:

Mask is a programmatic constraint (defined by developer) which ensures that the user enters a value inside a text field according to predefined format.

It is important to make a distinction between the terms “masking” and “validation”. Yes, both processes have a similar purpose. However, masking helps the user to enter a valid value, and validation only checks if the final value is correct (it only returns a boolean answer as a result).

If such a nerd definition still does not clarify things, then read my previous article. It has a more detailed explanation of masking.

To get more understanding of this concept, I also propose to look into some examples of masked text fields: for time, date, number, phone or credit card.

In the next two sections I will write about the history of Maskito’s development and explain the reasons for some of our architectural decisions. If you are not interested in these topics and are looking forward to seeing Maskito in action, please skip to the “Anatomy of Maskito” section.

A bit of history

All the necessary theoretical concepts have been discussed, and now I’m ready to explain why it was necessary to create a new library. Some readers may notice that some similar solutions are already available in open source.

I feel it necessary to have a disclaimer. There are no perfect libraries, frameworks, or languages. There is no silver bullet for all tasks. You decide which tool is better for your new case. Similarly with masking libraries — there are several popular libraries, but unfortunately not all of them were perfectly suited for our tasks.

My team is developing the design system of fintech company Tinkoff. We are responsible for maintaining the Angular UI Kit, a collection of libraries with a set of components. It is an Open Source project called Taiga UI.

Among our components there are a lot of masked text fields: for phone, for dates, for time, and even a complex component to enter credit card. I have mentioned only the most popular components, our UI Kit includes significantly more examples of masked text fields.

The text-mask library has historically been used for all our masked components. It provides a good public API, flexible enough to fit our requirements. However, if the library had no drawbacks, then you would not be reading this article now, because we would continue to use text-mask. But here we are.

Unfortunately, the library support gradually faded away, bugs were fixed less and less intensively. There are still unresolved issues in the project repository (for example, #657 and #830), discovered more than five years ago by our own colleagues, who at that moment were already developing Taiga UI.

Long-lived bugs are not the only problem. The codebase becomes less up to date with modern standards every day. And the most tragic event happened in 2020 — author of this project announced that the library was no longer maintained.

For these reasons, the goal of finding an alternative solution for text field masking was given high priority in our project:

  1. The above-mentioned long-lived bugs remained unresolved.
  2. The library became the only dependency outsider in our project: it was published using the legacy module systems. In addition, its Angular package was released under the legacy “ViewEngine” (instead of the modern “Ivy” engine). All of this causes build time warnings, and sooner or later this could become a serious problem.

We started looking into other popular masking solutions — imaskjs, cleave.js, ngx-mask and InputMask. The main advantage of all these solutions is simplicity to use. If you need to create some kind of classic mask that is not overcomplicated with additional logic, then they solve the task well.

But problems occur when you need to create a more complex solution with its own special behavior. The libraries didn’t provide the same flexible public API as it was in our previous library text-mask. Moreover, they don’t have detailed documentation, and an in-depth understanding of the library is possible only through exploring the outdated source code.

We’ve communicated with other developers who used the above-mentioned libraries in their projects. They claimed that they had faced SSR or Shadow DOM errors, caret jumping issues and so on. In general, as I said before, there are no perfect solutions, different tasks require different tools.

Finally, the history of the text-mask library shows that even a popular library can be retired if it is supported only by a few maintainers. Long-lived library should be backed by a huge team or even an entire organization that will always be interested in its further development.

If the library is maintained for the needs of the company, you can rely on it, because even if the existing maintainers decide to change the project or quit their jobs, the company will simply replace them with other employees since it is important for them to support the development of the library for their own needs.

All of our subsequent research and team discussions led to the decision that we should create our own library for masking that would satisfy all our needs.

How it was developed

Before starting the development, we defined the main tasks we wanted to achieve:

  1. The mask should support all user interactions with text fields: basic typing and deleting using the keyboard, pasting, dropping text in with the pointer, browser autofill, predictive text from mobile native keyboard.
  2. Server-side rendering support.
  3. The mask can be used not only with HTMLInputElement but also with HTMLTextAreaElement.
  4. Our new project should consist of several libraries and the main one should be framework independent. For popular web frameworks, we should publish optional tiny packages.

The first task was done with the help of modern browser capabilities. We used the beforeinput and input events to control all the necessary cases.

The second task about SSR was solved in the following way: all our Cypress tests are run on an SSR application. If an error is caught during server-side rendering, the application stops serving and all tests start failing immediately. This approach does not allow us to catch all bugs, but several times this strategy has helped catch SSR issues before they were released.

Our Maskito library is ready to use. It is published to npm and can be used in your projects. For example, it is already actively used in the popular Taiga UI project (all its masked text fields were developed using Maskito) and is endorsed as the recommended masking solution by Ionic Framework.

Anatomy of Maskito

Maskito is a collection of libraries. The main one @maskito/core is a lightweight 3kb package with no external dependencies. The core library is sufficient to mask the input in a simple vanilla javascript application.

There is also an optional framework-agnostic package @maskito/kit. It contains a set of configurable, ready-to-use masks.

For modern JavaScript frameworks, we have released small packages: for React, Angular and Vue. They are called @maskito/react, @maskito/angular and @maskito/vue respectively. They provide a convenient way to use Maskito in the style of those frameworks.

Maskito in action

Now let’s explore the basic concepts of Maskito. Look into an oversimplified piece of code:

import {Maskito, MaskitoOptions} from '@maskito/core';

const element: HTMLInputElement = document.querySelector('input')!;

const options: MaskitoOptions = {
mask: new RegExp('...'),
preprocessors: [
({elementState, data}) => {
return {elementState, data};
},
],
postprocessors: [
({value, selection}) => {
return {value, selection};
},
],
};

const maskedInput = new Maskito(element, options);

// Call it when the element is destroyed
maskedInput.destroy();

The main entity is the Maskito class, which is initialized with two arguments. The first is a reference to a native <input /> or <textarea /> element, and the second argument is the mask configuration.

After the class is initialized, native event listeners are enabled to control all user interaction with text boxes. The only thing the developer should care about is the need to clean up all listeners by calling the only public method destroy() of the class instance after the masked element is detached from the DOM.

You don’t need to worry about clean-ups if you use @maskito/react, @maskito/angular or @maskito/vue packages.

Let’s have a look at the configuration of the mask. In the code block above it is an object that implements the MaskitoOptions interface and is passed as the second argument to the Maskito class. Let’s learn the full power of mask configuration through an example. We will write a simple number input mask and iteratively improve it to demonstrate the power of Maskito.

The only required property is mask. It’s an expression that specifies the pattern which the final value of the text field should fit after all checks. It can be a classic regular expression, or it can be an array of mini-regular expressions. The last option is more complex, needed for masks with a fixed number of characters.

In the article, we will skip a more complex option of mask property. It is well described in the documentation, we will propose it as additional reading. For our task, an option with a simple regular expression is enough. The first version of mask for entering numbers is the following:

const maskitoOptions: MaskitoOptions = {
mask: /^\d+(,\d*)?$/,
};

We’ve created a regular expression that specifies a pattern for entering a number with an optional fractional part that uses a comma as a separator.

Let’s complicate the task. Some users commonly use a comma as a decimal separator, while others might argue that the point is the more commonly used separator.

If we try to enter a point in the current version of the form, the form will reject it. This is unacceptable if we are trying to get the perfect UX. Of course, you can extend the regular expression to allow the decimal point, and let the user decide which separator to use. Let’s imagine that, according to our design system, the text field should only contain a comma. If a user tries to enter a point, it should be automatically replaced by a comma.

For this case we can use an optional field from the MaskitoOptions interface — preprocessors (array of preprocessors). The preprocessor allows the developer to add custom value mutations before the mask starts its work. After all preprocessors have finished their work, the new value is passed to the mask.

The preprocessor is a pure function. The first argument is an object containing the current state of the element (the elementState property): the value of the text field and the start/end positions of the text selection. Also, the first argument contains the data property with value from the same property of the native event that was fired after the user’s interaction with the text field (for example, if the user types from the keyboard, data will contain the new character typed). And the preprocessor expects an object with the same interface as the return value. The developer can change all these values or leave them the same. We can implement our task by replacing a point with a comma as follows:

import {MaskitoOptions} from '@maskito/core';

const maskitoOptions: MaskitoOptions = {
mask: /^\d+(,\d*)?$/,
// 0.42 => 0,42
preprocessors: [
({elementState, data}) => {
const {value, selection} = elementState;

return {
elementState: {
selection,
value: value.replace('.', ','),
},
data: data.replace('.', ','),
};
},
],
};

Note that the point is not only replaced inside the data property, but also inside the value property! This is explained by the fact that while mutating the data property is sufficient for most cases, there is only one rare case where an invalid dot can be inside the value as well. This is browser autofill. Modern browsers do not fire a beforeinput event for this, and only a single input event is fired after browser autofill.

Let’s make one last improvement to our mask for entering numbers and add the following behavior: if the user tries to insert a number with a lot of leading zeros at the beginning of the integer part, then discard the extra ones. For example, if a user enters the string 000.42, the value of the text field should become 0.42.

There is another optional property inside the MaskitoOptions interface that is perfect for our new goal. It is postprocessors (array of postprocessors). Similar to its preprocessor counterpart, a postprocessor is a pure function to modify the value of a text field to implement its own special logic. However, it is called after the mask has finished its work: after mask discards all invalid characters and ensures that the value of the text field matches the mask property.

The first argument of the postprocessor is the state of the element: the new value of the text field and the new positions of the text selection (after all validations and calibrations of the mask). As a return value, the postprocessor expects an object with the same interface as it received from the first argument, but allows to change the value of any of its properties. And the new version of the mask configuration looks like this:

import {MaskitoOptions} from '@maskito/core';

const maskitoOptions: MaskitoOptions = {
mask: /^\d+(,\d*)?$/,
preprocessors: [
({elementState, data}) => {
const {value, selection} = elementState;

return {
elementState: {
selection,
value: value.replace('.', ','),
},
data: data.replace('.', ','),
};
},
],
// 000000.42 => 0.42
postprocessors: [
({value, selection}) => {
const [from, to] = selection;
const newValue = value.replace(/^0+/, '0');
const deletedChars = value.length - newValue.length;

return {
value: newValue,
selection: [from - deletedChars, to - deletedChars],
};
},
],
};

When using postprocessors, remember that this is the final step in validating and calibrating the value of the text field. You can make any changes you want, but at the end you must make sure that the final state of the element contains a valid value.

The postprocessor gives you a lot of flexibility, but as Uncle Ben said: “With great power comes great responsibility.”

In this article we have learned how to create a simple mask for entering numbers and we have become familiar with the basic concepts of Maskito! The final version of the example we’ve created can be further explored in the StackBlitz example:

Wrapping up

I have described only the core concepts of Maskito. But this is not all it is capable of. Maskito can do even more — you can read about it in the documentation.

If you like our new project, then star it on Github. And we always welcome your feedback! If you encounter any problems, then create an issue — we will do everything to fix it!

--

--