Far2l file manager

Far2l is a port of Far Manager v2 that runs on Linux and Mac.

Installation

On Linux distros, first search for far2l package. If it’s not awailable, you have to build it by yourself.

On macs, use the brew package manager:

brew install --cask far2l

Screenshots

Far2l main screenshot

Usage

Interface is pretty the same, but of course far2l is not so much powerful as original Far Manager.

Basic commands for file manipulations are the same as in Far:

  • Tab - jump between left and right panels
  • F5 - Copy file under cursor/selection to the opposite panel
  • F6 - Move file under cursor/selection to the opposite panel
  • Shift-F5 - Create copy of a file file in the same panel
  • Shift-F6 - Rename current file
  • Enter - open file
  • Shift+arrows up/down - select files
  • F3 - View file under cursor
  • F4 - Edit file under cursor

Development state

As of 2024, far2l seems actively developed.

JS: focus on the first input with an error

In this article, I’ll show you how you can easily focus on the first element with an error on your form. The methodology is framework-agnostic, so you can easily adopt it to your framework (if you use any).

TL;DR

Add a data-error attribute to an element if it has an error and then focus on it with querySelector + focus().

Project setup

We’ll use vite + vanilla ts for our project:

npm create vite@latest my-vue-app -- --template vanilla-ts

Now we need some cleanup - remove all content from the styles.css file as well as from the main.ts.

Create a form

First, let’s add markup for our form (main.ts file):

import './style.css'

document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<div class="personal-form">
  <div class="text-input">
    <label for="name">Name</label>
    <input id="name" type="text">
    <div class="error-label" id="error-name"></div>
  </div>

  <div class="text-input">
    <label for="email">Email</label>
    <input id="email" type="text">
    <div class="error-label" id="error-email"></div>
  </div>

  <div class="text-input">
    <label for="address">Address</label>
    <input id="address" type="text">
    <div class="error-label" id="error-address"></div>
  </div>

  <div class="text-input">
    <label for="passport">Passport</label>
    <input id="passport" type="text">
    <div class="error-label" id="error-passport"></div>
  </div>
  <button id="send"> Send </button>
</div>
`

And this is our css (style.css file):

:root {
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;
}

.text-input {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.personal-form {
  display: flex;
  flex-direction: column;
  max-width: 70ch;
  margin: 0 auto;
  gap: 1rem;
}

.error-label {
  color: red;
  font-size: 14px;
}

.error-label:empty {
  display: none;
}

The most important thing here is the error-label class - we hide error blocks if they’re empty( with the help of the empty pseudo-class).

Now our page looks this way:

form image

Define data structure

We store each field’s value in its own variable:

let name = ''
let email = ''
let address = ''
let passport = ''

Errors are stored in a single object:

const errors = {
  name: '',
  email: '',
  address: '',
  passport: '',
}

We need to update our variables’ values when text in fields is changed:

function updateValue(inputId, cb: (value: string) => void) {
  document.getElementById(inputId).addEventListener('input', (e: Event) => {
    const value = (e.target as HTMLInputElement).value
    cb(value)
  })
}

This function adds a listener to the input’s input event and we exute it for each of our fields:

updateValue('name', (value) => name = value)
updateValue('email', (value) => email = value)
updateValue('address', (value) => address = value)
updateValue('passport', (value) => passport = value)

When the text in a field is changed, a callback function is executed, which in its turn assigns this value to a corresponding variable.

And finally, code that is responsible for the form’s logic:

function onSubmit() {
  validate()
  displayErrors()
  focusOnError()
}

document.getElementById('send').addEventListener('click', onSubmit)

The idea is simple -

Now we need to implement each from these steps.

Add validation

Our validation logic lives in the validate function:

function validate() {
  // First of all, reset all error messages
  for (const key in errors)
    errors[key] = ''

  if (!name.length)
    errors.name = 'Required field'
  else if (name.length > 30)
    errors.name = '30 characters max'

  if (!passport.length)
    errors.passport = 'Required field'
  else if (passport.length !== 13)
    errors.passport = 'Pasport id should be 13 characters long'
}

It validates only two fields, but that is enough for the demonstration. You can read article about validation with the help of the zod framework here. It is worth noting that this function doesn’t do anything with visual representation on the page - it only validates variables’ values and writes error messages to another variables.

Display errors

After our form’s data is validated, it’s time to display errors if there are any. The algorithm is simple: we iterate over the errors object and update inner text of the error labels with corresponding value. Each error label’s id contains field name (error-nameerror-passporterror-email etc), so we can rich them with document.getElementById function.

Besides that, we are adding a data-error attribute to the input fields which are invalid.

So, here it is:

function displayErrors() {
  for (const key in errors) {
    setErrorValue(key, errors[key])

    if (errors[key])
      document.getElementById(key)?.setAttribute('data-error', errors[key])
    else
      // Don't forget to remove attribute from the input field if its value
      // is correct
      document.getElementById(key)?.removeAttribute('data-error')
  }
}

setErrorValue function searches for an error label and updates its text:

function setErrorValue(field: string, error: string) {
  const el = document.getElementById(`error-${field}`)
  if (el)
    el.innerText = error
}

Set focus on a field with an error

We use document.querySelector to get the first element in the DOM tree with a data-error attribute and set focus on it:

function focusOnError() {
  document.querySelector('[data-error]')?.focus()
}

That’s all! Now, on submit, focus will be set on the first field with an invalid value:

validation result

Full source code

src/main.ts:

import './style.css'

document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<div class="personal-form">
  <div class="text-input">
    <label for="name">Name</label>
    <input id="name" type="text">
    <div class="error-label" id="error-name"></div>
  </div>

  <div class="text-input">
    <label for="email">Email</label>
    <input id="email" type="text">
    <div class="error-label" id="error-email"></div>
  </div>

  <div class="text-input">
    <label for="address">Address</label>
    <input id="address" type="text">
    <div class="error-label" id="error-address"></div>
  </div>

  <div class="text-input">
    <label for="passport">Passport</label>
    <input id="passport" type="text">
    <div class="error-label" id="error-passport"></div>
  </div>
  <button id="send"> Send </button>
</div>
`

let name = ''
let email = ''
let address = ''
let passport = ''

const errors = {
  name: '',
  email: '',
  address: '',
  passport: '',
}

function updateValue(inputId, cb: (value: string) => void) {
  document.getElementById(inputId).addEventListener('input', (e: Event) => {
    const value = (e.target as HTMLInputElement).value
    cb(value)
  })
}

updateValue('name', (value) => name = value)
updateValue('email', (value) => email = value)
updateValue('address', (value) => address = value)
updateValue('passport', (value) => passport = value)

function onSubmit() {
  validate()
  displayErrors()
  focusOnError()
}

document.getElementById('send').addEventListener('click', onSubmit)

function validate() {
  // First of all, reset all error messages
  for (const key in errors)
    errors[key] = ''

  if (!name.length)
    errors.name = 'Required field'
  else if (name.length > 30)
    errors.name = '30 characters max'

  if (!passport.length)
    errors.passport = 'Required field'
  else if (passport.length !== 13)
    errors.passport = 'Pasport id should be 13 characters long'
}

function setErrorValue(field: string, error: string) {
  const el = document.getElementById(`error-${field}`)
  if (el)
    el.innerText = error
}

function displayErrors() {
  for (const key in errors) {
    setErrorValue(key, errors[key])
    if (errors[key])
      document.getElementById(key)?.setAttribute('data-error', errors[key])
    else
      document.getElementById(key)?.removeAttribute('data-error')
  }
}

function focusOnError() {
  document.querySelector('[data-error]')?.focus()
}

src/style.css:

:root {
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;
}

.text-input {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.personal-form {
  display: flex;
  flex-direction: column;
  max-width: 70ch;
  margin: 0 auto;
  gap: 1rem;
}

.error-label {
  color: red;
  font-size: 14px;
}

.error-label:empty {
  display: none;
}

Pros and cons

  • Framework-agnostic, can be used with vanilla JS
  • Won’t work if html has non-default order of elements (because querySelector returns the first matched element)

Vue: How to check for a specific parent component

The problem: you want to make sure that a component is placed inside some other component.

The reason why you may want to do this is that your component has no sense when used outside its parent. For example:

<ContextMenu>
  <ContextMenuItem icon="home-icon" text="Home" />
  <ContextMenuItem icon="cut-icon" text="Cut" />
  <ContextMenuItem icon="paste-icon" text="Paste" />
</ContextMenu>

Here, the ContextMenuItem is usable only as a “brick” of the ContextMenu.

In such cases, you can use provide/inject:

  1. Provide a value in ContextMenu
  2. Inject the value in ContextMenuItem. If it’s not defined, throw an error.

Example

As usual, create an empty vite + vue + typescript project:

npm create vite@latest parent-component -- --template vue-tsc

First of all, we need a key for provide/inject functinos, so let’s add it. Create the key.ts file in the root of the project with this content:

import type { InjectionKey } from 'vue'
export const key = Symbol() as InjectionKey<string>

Then, in the components directory, create a ContextMenu.vue file:

<script setup lang="ts">
import { provide, inject } from 'vue'
import { key } from '../key'

provide(key, 'value')
</script>

<template>
  <ul>
    <slot />
  </ul>
</template>

Then, create a ContextMenuItem.vue file:

<script setup lang="ts">
import { inject } from 'vue'
import { key } from '../key'

const val = inject(key)

if (!val)
  throw new Error('ContextMenuItem can be used only inside ContextMenu component!')
</script>

<template>
  <li> Menu Item </li>
</template>

And finally, let’s show our ContextMenu component in the App.vue:

<script setup lang="ts">
import ContextMenu from './components/ContextMenu.vue'
import ContextMenuItem from './components/ContextMenuItem.vue'
</script>

<template>
  <div>
    <ContextMenu>
      <ContextMenuItem />
      <ContextMenuItem />
      <ContextMenuItem />
    </ContextMenu>
  </div>
</template>

Now, on our main page, we’ll see three “Menu item” strings, one per each ContextMenuItem. Open dev console, and make sure you don’t see any errors or warnings.

Everything works as expected - ContextMenuItem is inside ContextMenu. But if we remove ContextMenu component, our dev console will show us an error:

error from the ContextMenuItem component

Should I always check for such situations?

Of course no. Check for the parent component if you really need this. If you’re in doubt, I consider to update your documentation instead of adding more JS code to your project.

References

vue-tsc in watch mode

vue-tsc can be run in a watch mode and report any typescript errors as you change files in a project.

Simply run it with this command:

npx vue-tsc --noEmit --watch

Why you may want to use it? I personally prefer to disable any linter/LSP messages when I need more concentration on a task and I don’t want to be distracted by UI buzz that IDE or editor’s plugins create.

VSCode does it better than Sublime

I found a few things that VSCode does better than Sublime Text.

Creating new files from the sidebar

When you create a new file in the sidebar file navigator, VSCode asks for a filename first, and only then opens the file itself. In Sublime Text, after clicking on “New File” context menu item, new tab is opened, and you type a filename only when saving. Creating a file first is better, because in this case your file will have an extension, and proper mode and syntax highlighting will be used when you’ll start writing.

Sidebar position (Not actual anymore)

Update: In 2025, Sublime Text has an option to move the sidebar to the right side.

In VSCode, the sidebar can be moved to the right side. This is very useful when you often toggle the sidebar (on small screens, for example) - in this case your code is not shifted to the right side each time the sidebar is shown.

Vocaloid music genre

Vocaloid is a music genre where voice is synthesized with a software.

It’s named so because of the Vocaloid software, made by Yamaha, which synthesizes singing voices.

Hatsune Miku

Hatsune MikuWikipedia - Hatsune Miku is a character for a voice bank for Vocaloid, released in 2007.

Hatsune Miku

She is very popular, people go to her concerts, there are a lot of fan stuff with her - figures, posters, t-shirts, you name it. She’s not the only one or first virtual voice characters, by the way. MEIKO, for example, was released three years before Miku.

MEIKO

Hatsune Miku

Links

  • Vocaloid famdom wiki. Most probably the only resource you need to dive into the world of vocaloid program and music created with it.

Android apps that I think are great

A list of android apps I can recommend.

OLauncher

If you want a simple and elegant android launcher, Olauncher is for you. It removes all icons from the screen and allows you to add just 5 applications to the main screen. These apps are displayed as text - no icons. You can swipe up to see the full list of applications. Also supports swipe customization - for example, you can set left swipe to open contacts, and right to launch the camera.

Markor

Markor is a markdown editor with a lot of additional features, such as support for todo.txt format, export to pdf, html and image formats (and yet it’s not all available formats). Todotxt support is great - markor can move completed tasks to the archive file, so your todo.txt will be always clean. The “quick notes” feature is also a great idea - basically it’s just a simple markdown file, but in the app you have a separate button to open it quickly.

DroidFS

DroidFS allows you to create an encrypted container, where you can put all sensitive stuff. Supports wiping imported files from the phone memory.

Etar

A good calendar app. Nothing more, nothing less.

Foobar

The old good foobar music player.

Using JsDoc for typing

Typescript is a new standard in the Javascript world, and almost all tools and frameworks can deal with it now, but despite that, it’s also true that the existence of transpile step complicates development and hides actual code in a “black box”. In this post, we’ll see how we can write plain javascript code yet use typescript for type checks.

What is JSDoc

JsDoc is a tool that can generate API documentation from comments that formatted in a specific way, for example:

/**
 * Multiply two numbers
 *
 * @param {number} a - First number
 * @param {number} b - Second number
 *
 * @returns {number}
 */
function multiply(a, b) {
	return a * b;
}

In this example, we have documented a function and its parameters, as well as function and parameters’ types.

Beside simple cases, we can define array types, objects, type unions, export types from other files and packages, and all of that is done inside comment blocks. Following are examples of some JsDoc comments.

General syntax, parameters and return types

A Jsdoc command starts with the /** multi-line comment. Note that comments that don’t start with /** are not treated as JsDoc comments - /*** and /* comments won’t be parsed. Then documentation text goes. When we want to document parameters, we use @param tag:

@param {string} userName User name

@param tag has “{type} parameterName parameterDescription” form. To document return type, @returns tag is used:

@returns {number}

Optional parameters

We can make a parameter optional by wrapping it in square brackets:

/**
 * @param {string} [name]
 */
function printName(name="Name") {
	console.log(name);
}

Any type

/**
 * @param {*} value
 */

Default values

/**
 * @param {string} [somebody=John Doe] - Somebody's name.
 */

Documentation

Union types

Here, the id parameter may be string or number:

/**
 * Get user by id
 * @param {string | number} id User id
 */
function getUser(id) {
	//...
}

Arrays

/**
 * @param {number[]} keys - List of keys
 */

Complex types

We can define our own types with @typedef tag:

/**
 * @typedef {Object} User
 * @property {number} id - User's id
 * @property {string} name - User name
 * @property {string} email - User's email
 */

 /**
  * Get user's info
  *
  * @param {number} id User id
  * @returns {User}
  */
function getUser(id) {
    //...
}

As you can see, we can declare a type with the @typedef tag and then use it as if it’s a normal type.

Import type from another file

It’s often needed to use a type declared in another file. Taking our previous example, we may want to create a userHelper file, which will contain helper functions that use the User type. Re-defining the User type is a nonsense. Luckily, we can import a type that was defined in another file:

/**
 * Returns user's age
 *
 * @param {import ('./user.js').User} user
 * @returns {number}
 */
function getAge(user) {
    //...
}

Variable typing

Sometimes we may want to set a type for a variable. In JsDoc, @type tag is used for this purpose:

/**
 * @type {import ('./user.js').User}
 */
const user = {
    name: "John Doe",
    email: "j.doe@email.com"
}

Now typescript will process the object behind the user variable as a User type that was defined in the user.js file.

Import type from an other package

We can import types from modules as well:

@returns {import("node:child_process").SpawnSyncReturns<Buffer | string | undefined>}

Add typescript

To take an advantage of typescript with JsDoc, we need two things:

  1. Add typescript support to our project
  2. Add // @ts-check comment to the beginning of file

Now, when we run tsc --noEmit command, typescript will check types correctness in all files that have the @ts-check directive.

LSP works fine with this approach, too - you can enjoy hints, autocomplete, error messages and documentation popups right in your text editor (below are screens of Sublime Text with the LSP plugin in action):

JsDoc popup:

LSP popup screenshot

Implict Any type warning:

LSP popup screenshot

Wrong type error:

LSP popup screenshot

Links


Vue: input data validation with zod

This post consists from two parts - the first one is a small introduction to the zod library, and the second one is about how we can use it for form validation in Vue.

Source code

Zod

Zod is a javascript library for data validation. It has pretty small bundle size, works with typescript and javascript, and it’s easy to start with.

Philosophy

Zod is a library for data validation - it doesn’t work with UI elements - only with plain js data. It means that you can use zod on a server side, as well as on a client. It also means that you can’t use it for form validation until form’s input controls are not bounded to a javascript data structure(s).

Object validation

To validate something with zod, you first need to create a validation schema - a special object that will parse the data.

import {z} from "zod";

const userSchema = z.object({
	name: z.string(),
	age: z.number()
	});

userSchema.parse({name: "Username"});

All fields are required by default. To make some property optional, use optional():

import {z} from "zod";

const userSchema = z.object({
	name: z.string(),
	age: z.number().optional() // number | undefined
	});

userSchema.parse({name: "Username"});

Parse method returns a deep copy of input variable or throws an error if a value doesn’t pass validation. As an alternative, we can use safeParse method, which returns either a copy of input variable or an error. A little remark about return types - in zod, there’s a Input type and a Output type. Usually, they’re the same. But since zod supports data transformation, the output type may differ from the input type.

Schema reusability

We can re-use already defined schemas:

import {z} from "zod";

const contactsSchema = z.object({
	email: z.string().email(),
	phone: z.string().max(50)
	});

const userSchema = z.object({
	name: z.string().max(50),
	contacts: contactsSchema
	});

Here, we’ve reused contactsSchema inside userSchema declaration. In general, it’s a good idea to decompose complex schemas into smaller ones - it’s the same thing as creating functions to handle complex logic.

Refinements

Refinements in zod allow you to create your own validation logic. Documentation is pretty clear on how to use refinements, so I won’t copy examples here.

Using zod for input validation in Vue

Form

We will validate a form with personal user information. It will contain these fields:

  • Username (required, max 50 characters)
  • Email (required)
  • Real name (optional, max 100 characters)
  • City (required, max 100 characters)
  • Work email (by default has the same value as Email)

In this form, the field with work email will be hidden by default, and a “Same as the main email” checkbox will hide/show it. If it’s visible, it’s required. If it’s not visible, it’s not required, because it has the same value as the “Email” field.

First, we need to create a new Vue project:

npm create vue@3

We don’t need any libraries except typescript, so don’t forget to include it in the project during creation.

So, here our starting point - a form with input fields, where each input is bounded to a data model, and a “Submit” button. When a user clicks the button, state of the form is showing up. There’re no any error checks yet, but we’ll add them later.

<script setup lang="ts">
<script setup lang="ts">
import {shallowReactive} from "vue";

interface PersonalInfo {
  username?: string;
  email?: string;
  name?: string;
  city?: string;
  workEmail?: string;

  /**
   * True if workEmail is the same as main email
   */
  sameEmail: boolean;
}


const formData = shallowReactive<FormData>({
  sameEmail: true
})

function onSubmit() {
  alert(JSON.stringify(formData, null, 2));
}

</script>

<template>
<div class="container">
  <h1>Personal info</h1>
    <div class="input">
      <label for="username"> Username </label>
      <input name="username" v-model="formData.username"/>
    </div>
    <div class="input" >
      <label for="email"> Email </label>
      <input name="email" v-model="formData.email"/>
    </div>
    <div class="input">
      <label for="name"> Real name </label>
      <input name="name" v-model="formData.name"/>
    </div>
    <div class="input">
      <label for="city"> City  </label>
      <input name="city" v-model="formData.city"/>
    </div>
    <div>
      <label for="sameEmail"> Work email is the same as the main  </label>
      <input type="checkbox" name="sameEmail" v-model="formData.sameEmail" />
    </div>
    <div v-if="!formData.sameEmail" class="input">
      <label for="workemail"> Work email  </label>
      <input name="workemail" v-model="formData.workEmail"/>
    </div>

    <button @click="onSubmit"> Submit </button>
  </div>
</template>

<style scoped>
.input {
display: flex;
  gap: 0.3rem;
  flex-direction: column;
}
.container {
  display: flex;
  gap: 1rem;
  flex-direction: column;
}

button {
  font-size: 1.5rem;
  margin-top: 1.5rem;
  background-color: blue;
}
</style>

</script>

<template>
<div class="container">
  <h1>Personal info</h1>
    <div class="input">
      <label for="username"> Username </label>
      <input name="username"/>
    </div>
    <div class="input" >
      <label for="email"> Email </label>
      <input name="email" />
    </div>
    <div class="input">
      <label for="name"> Real name </label>
      <input name="name" />
    </div>
    <div class="input">
      <label for="city"> City  </label>
      <input name="city" />
    </div>
    <div class="input">
      <label for="workemail"> Work email  </label>
      <input name="workemail" />
    </div>

    <button> Submit </button>
  </div>
</template>

<style scoped>
.input {
display: flex;
  gap: 0.3rem;
  flex-direction: column;
}
.container {
  display: flex;
  gap: 1rem;
  flex-direction: column;
}

button {
  font-size: 1.5rem;
  margin-top: 1.5rem;
  background-color: blue;
}
</style>

Now our form looks like this:

Form screenshot

And when a user clicks the “Submit” button, we show the value of the formData variable:

Form screenshot

Display error messages

Our form now accepts all possible string values, without any restrictions. It’s time to add validation and tell the user what fields were filled are incorrect. Let’s add error messages to the input fields (you can check out input css class in the full source code at the end of the post):

<div class="input">
  <label for="city"> City  </label>
  <input name="city" v-model="formData.city"/>
  <div class="error"> city error </div>
</div>

Now each of our fields has an error label:

Form screen

What we want now is to validate all fields and show corresponding error messages for all invalid fields when a user clicks the “Submit” button. First of all, we need a validation schema:

const personalSchema = z.object({
  username: z.string().max(50),
  email: z.string().email(),
  name: z.string().max(100),
  city: z.string().max(100),
  sameEmail: z.boolean(),
  workEmail: z.string().optional()
}).refine((val) => {
  const emailRegex =
  /^([A-Z0-9_+-]+\.?)*[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i;

  return val.sameEmail || emailRegex.test(val.workEmail)
}, {message: "Invalid email", path: ["workEmail"]})

It doesn’t look very simple for such task, though. Let’s take a closer look at what we have here.

username: z.string().max(50),
email: z.string().email(),
name: z.string().max(100),
city: z.string().max(100),
sameEmail: z.boolean(),

These lines describe rules for our fields, and everything is straightforward here.

...
workEmail: z.string().optional()
}).refine((val) => {
  const emailRegex =
  /^([A-Z0-9_+-]+\.?)*[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i;

  return val.sameEmail || (val.workEmail ? emailRegex.test(val.workEmail) : false)
}, {message: "Invalid email", path: ["workEmail"]})

This is the root of our schema’s ugliness. The workEmail field is optional when the sameEmail flag is true, and when this flag is false, workEmail should be validated as an email field. In zod, for validations that require a context (and we need one here, because the result of validation relies on another field’s value), the refine() method is used. Its first argument is a function that accepts the whole schema as a parameter, and the truthiness of its result leads to passing the validation. The second parameter is settings - we set the error message and and the error’s path.

This line returns true if the sameFlag is true, or returns the result of email validation, which is done by regular expression. I’ve grabbed this regex from zod’s source code, by the way:

return val.sameEmail || (val.workEmail ? emailRegex.test(val.workEmail) : false)

Run validation

First of all, we need zod itself:

npm i zod

We want to show error messages only when a user presses the “Submit” button. After that, any changes in input fields should re-launch validation. Also, we need a convenient way to get error messages. First, we need a variable that will hold either the submit button was pressed or not:

const isValidationPerformed = shallowRef(false)

Then, we create a computed variable that returns the list of validation errors:

const errors = computed(() => {
    if (!isValidationPerformed.value) {
      return undefined;
    }

    const validationResult = personalSchema.safeParse(formData)
    return validationResult.success ? undefined : validationResult.error.format()

})

A few more notes:

  • We use safeParse because we don’t want to throw errors

  • We use error.format() to format our errors into a convenient form. This method returns an object with messages in this kind of format:

    {
    	username: {
    		_errors: ["Required field"]
    	},
    	email: {
    		_errors: ["Invalid email"]
    	}
    	city: {
    		_errors: ["Required field"]
    	},
    
    }
    

Inside onSubmit function we set isValidationPerformed to true:

isValidationPerformed.value = true;

And finally, we need to display our error messages. We always display only first error message from the list:

<div class="input">
    <label for="username"> Username </label>
    <input name="username" v-model="formData.username"/>
    <div class="error"> {{errors?.username?._errors[0]}} </div>
</div>

We use optional chaining, because we don’t know if the specified field exists in the errors object.

How it works

  1. By default, isValidationPerformed is set to false. User can edit any field, and no error messages will be shown.
  2. When a user clicks the “Submit” button, we set isValidationPerformed variable to true.
  3. isValidationPerformed is a reactive dependency in the errors computed, so it’s recalculated when isValidationPerformed has changed.
  4. If errors has any errors, they’re shown under the input fields.
  5. Since isValidationPerformed is true, any changes in the formData object also trigger errors computed recalculation, so when a user fixes an input value, the corresponding error message is disappeared (again, because of error’s recalculation)

Dealing with empty strings

If we press the “Submit” button when the form is empty, the “Required” error will be shown under our fields:

Form screenshot

When we type something in, for example, the “username” field, the error under the input field is disappeared:

Form screenshot

But if we clear the field, no any error message will be shown:

Form screenshot

That’s because now our formData.username contains an empty string, not an undefined value, and this situation fully satisfies the validation condition (it’s a string and its length don’t overflow the limit). If we want to forbid empty and space-only strings, we may use trim() and min() combination:

// Create a separate zod schema for non-empty strings
const nonEmptyString = z.string().trim().min(1);

const personalSchema = z.object({
  username: nonEmptyString.max(50),
  email: nonEmptyString.email(),
  name: nonEmptyString.max(100),
  city: nonEmptyString.max(100),
  sameEmail: z.boolean(),
  workEmail: nonEmptyString.optional()
}).
refine((val) => {
  const emailRegex =
  /^([A-Z0-9_+-]+\.?)*[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i;

  return val.sameEmail || emailRegex.test(val.workEmail)
}, {message: "Invalid email", path: ["workEmail"]})

Now everything works fine:

Form validation screenshot

The full code

<script setup lang="ts">
import {shallowReactive, shallowRef, computed} from "vue";
import {z} from "zod";

export interface PersonalInfo {
  username?: string;
  email?: string;
  name?: string;
  city?: string;
  workEmail?: string;

  /**
   * True if workEmail is the same as main email
   */
  sameEmail: boolean;
}

const nonEmptyString = z.string().trim().min(1);

const personalSchema = z.object({
  username: nonEmptyString.max(50),
  email: nonEmptyString.email(),
  name: nonEmptyString.max(100),
  city: nonEmptyString.max(100),
  sameEmail: z.boolean(),
  workEmail: nonEmptyString.optional()
}).
refine((val) => {
  const emailRegex =
  /^([A-Z0-9_+-]+\.?)*[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i;

  return val.sameEmail || (val.workEmail ? emailRegex.test(val.workEmail) : false)
}, {message: "Invalid email", path: ["workEmail"]})

const isValidationPerformed = shallowRef(false)


const formData = shallowReactive<PersonalInfo>({
  sameEmail: true
})


const errors = computed(() => {
    if (!isValidationPerformed.value) {
      return undefined;
    }

    const validationResult = personalSchema.safeParse(formData)

    return validationResult.success ? undefined : validationResult.error.format()

})


function onSubmit() {
  isValidationPerformed.value = true;

  if (!errors.value) {
    alert(JSON.stringify(formData, null, 2));
    alert("All good!")
  } else {
    alert(JSON.stringify(errors.value, null, 2))
  }
}

</script>

<template>
<div class="container">
  <h1>Personal info</h1>
    <div class="input">
      <label for="username"> Username </label>
      <input name="username" v-model="formData.username"/>
      <div class="error"> {{errors?.username?._errors[0]}} </div>
    </div>
    <div class="input" >
      <label for="email"> Email </label>
      <input name="email" v-model="formData.email"/>
      <div class="error">  {{errors?.email?._errors[0]}} </div>
    </div>
    <div class="input">
      <label for="name"> Real name </label>
      <input name="name" v-model="formData.name"/>
      <div class="error">  {{errors?.name?._errors[0]}} </div>
    </div>
    <div class="input">
      <label for="city"> City  </label>
      <input name="city" v-model="formData.city"/>
      <div class="error">  {{errors?.city?._errors[0]}} </div>
    </div>
    <div>
      <label for="sameEmail"> Work email is the same as the main  </label>
      <input type="checkbox" name="sameEmail" v-model="formData.sameEmail" />
    </div>
    <div v-if="!formData.sameEmail" class="input">
      <label for="workemail"> Work email  </label>
      <input name="workemail" v-model="formData.workEmail"/>
      <div class="error">  {{errors?.workEmail?._errors[0]}} </div>
    </div>

    <button @click="onSubmit"> Submit </button>
  </div>
</template>

<style scoped>
.input {
display: flex;
  gap: 0.3rem;
  flex-direction: column;
}
.container {
  display: flex;
  gap: 1rem;
  flex-direction: column;
}

button {
  font-size: 1.5rem;
  margin-top: 1.5rem;
  background-color: blue;
}

.error {
  margin-top: -0.5rem;
  color: red;
  font-size: 0.5rem;
}
</style>

Additional information and similar libraries

Schema type inference

In our example, we declared our form interface first, but it’s also possible to infer the whole type from a zod schema:

type PersonalInfo = z.infer<typeof personalSchema>;

Similar libraries

There’re also:

  • Vee validate. Probably the most popular validation solution for Vue. I personally don’t like it much, yet it doesn’t cancel the fact that this is a great library.
  • Vuelidate. Vue framework that validates data. Generally, it has the same concept as in our implementation.
  • Yup. For me, one of the benefits of yup over zod is the when() method, with which you can create conditional validation. By the way, vee-validate uses yup by default as a validation engine.