Обязательные и необязательные ключи

Валидация объектов — одна из центральных задач библиотеки Joi. При работе со структурами данных необходимо контролировать:

  • какие поля обязательны;
  • какие поля могут отсутствовать;
  • какие поля допускают null;
  • какие значения разрешены при отсутствии данных;
  • как обрабатывать неизвестные ключи.

Базовый механизм реализуется через методы:

  • required()
  • optional()
  • forbidden()
  • presence()
  • default()
  • exist()

Поведение Joi по умолчанию

Если ключ описан в схеме, но не помечен как обязательный, он считается необязательным.

const Joi = require('joi');

const schema = Joi.object({
    username: Joi.string(),
    age: Joi.number()
});

Корректны оба варианта:

{}
{
    username: 'alex'
}
{
    username: 'alex',
    age: 25
}

Ошибка возникнет только при несоответствии типа:

{
    age: '25'
}

Результат:

"age" must be a number

Метод required()

Метод required() делает поле обязательным.

const schema = Joi.object({
    username: Joi.string().required(),
    password: Joi.string().required()
});

Теперь отсутствие ключей вызывает ошибку:

{}

Результат:

"username" is required

Если отсутствует только один ключ:

{
    username: 'alex'
}

Результат:

"password" is required

Эквивалентная запись

Метод required() имеет короткий псевдоним — required.

Joi.string().required()

Также допустимо:

Joi.string().presence('required')

Проверка наличия ключа

Важно понимать разницу между:

  • отсутствием поля;
  • значением undefined;
  • значением null;
  • пустой строкой.

Отсутствующий ключ

{}

undefined

{
    username: undefined
}

Для required() оба варианта считаются ошибкой.


required() и undefined

const schema = Joi.object({
    username: Joi.string().required()
});

Проверка:

schema.validate({
    username: undefined
});

Результат:

"username" is required

required() и null

null не считается отсутствующим значением. Это отдельный тип.

schema.validate({
    username: null
});

Результат:

"username" must be a string

Чтобы разрешить null, используется allow(null).

const schema = Joi.object({
    username: Joi.string()
        .allow(null)
        .required()
});

Теперь допустимо:

{
    username: null
}

Метод optional()

optional() явно помечает поле как необязательное.

const schema = Joi.object({
    middleName: Joi.string().optional()
});

Практической разницы с обычным объявлением нет:

Joi.string()

и

Joi.string().optional()

работают одинаково.


Когда optional() полезен

Явное указание необязательности делает схему более читаемой.

const schema = Joi.object({
    login: Joi.string().required(),
    password: Joi.string().required(),

    avatar: Joi.string().optional(),
    bio: Joi.string().optional(),
    website: Joi.string().optional()
});

Такая схема визуально разделяет обязательные и дополнительные данные.


Метод forbidden()

forbidden() запрещает присутствие ключа.

const schema = Joi.object({
    id: Joi.number().forbidden(),
    username: Joi.string().required()
});

Корректно:

{
    username: 'alex'
}

Ошибка:

{
    id: 10,
    username: 'alex'
}

Результат:

"id" is not allowed

Практическое применение forbidden()

Запрет системных полей

const createUserSchema = Joi.object({
    id: Joi.forbidden(),
    createdAt: Joi.forbidden(),

    username: Joi.string().required(),
    email: Joi.string().email().required()
});

Клиент не сможет подменить системные значения.


Метод presence()

Метод presence() задаёт режим присутствия поля.

Допустимые значения:

  • 'required'
  • 'optional'
  • 'forbidden'

Пример:

Joi.string().presence('required')

Эквивалент:

Joi.string().required()

Глобальная настройка presence

Режим можно назначить всей схеме.

const schema = Joi.object({
    username: Joi.string(),
    email: Joi.string(),
    age: Joi.number()
}).prefs({
    presence: 'required'
});

Теперь все поля обязательны.

Корректно:

{
    username: 'alex',
    email: 'alex@mail.com',
    age: 30
}

Ошибка:

{
    username: 'alex'
}

Результат:

"email" is required

Переопределение глобального режима

Даже при глобальном required отдельные поля можно сделать необязательными.

const schema = Joi.object({
    username: Joi.string(),
    email: Joi.string(),
    avatar: Joi.string().optional()
}).prefs({
    presence: 'required'
});

Теперь avatar можно не передавать.


Метод exist()

exist() проверяет только наличие значения.

const schema = Joi.object({
    token: Joi.exist()
});

Допустимо:

{
    token: 123
}
{
    token: false
}
{
    token: null
}

Ошибка:

{}

Разница между required() и exist()

required()

Проверяет:

  • наличие значения;
  • соответствие типу.
Joi.string().required()

exist()

Проверяет только наличие.

Joi.exist()

default() и необязательные ключи

Метод default() задаёт значение по умолчанию.

const schema = Joi.object({
    role: Joi.string().default('user')
});

Проверка:

schema.validate({});

Результат:

{
    value: {
        role: 'user'
    }
}

default() и required()

Интересная особенность:

const schema = Joi.object({
    role: Joi.string()
        .required()
        .default('user')
});

Несмотря на required(), ошибка не возникнет.

default() автоматически подставит значение.


Значения по умолчанию через функцию

const schema = Joi.object({
    createdAt: Joi.date().default(() => new Date())
});

Функция вызывается во время валидации.


Обязательные вложенные ключи

const schema = Joi.object({
    user: Joi.object({
        name: Joi.string().required(),
        email: Joi.string().required()
    }).required()
});

Теперь обязательны:

  • объект user;
  • поля name и email внутри него.

Ошибки вложенных объектов

Проверка:

{
    user: {}
}

Результат:

"user.name" is required

Необязательные вложенные объекты

const schema = Joi.object({
    profile: Joi.object({
        bio: Joi.string(),
        website: Joi.string()
    }).optional()
});

Допустимо:

{}

и

{
    profile: {
        bio: 'Developer'
    }
}

Комбинация required() и allow(null)

Частая схема:

const schema = Joi.object({
    avatar: Joi.string()
        .allow(null)
        .required()
});

Поле обязательно должно присутствовать, но может содержать null.

Допустимо:

{
    avatar: null
}

Ошибка:

{}

Пустые строки и required()

По умолчанию пустая строка не считается валидной.

const schema = Joi.object({
    username: Joi.string().required()
});

Проверка:

{
    username: ''
}

Результат:

"username" is not allowed to be empty

Разрешение пустых строк

const schema = Joi.object({
    username: Joi.string()
        .allow('')
        .required()
});

Теперь допустимо:

{
    username: ''
}

empty()

Метод empty() преобразует указанные значения в undefined.

const schema = Joi.object({
    username: Joi.string()
        .empty('')
        .required()
});

Теперь пустая строка будет считаться отсутствующим значением.

Проверка:

{
    username: ''
}

Результат:

"username" is required

Условная обязательность

Часто обязательность поля зависит от других полей.

const schema = Joi.object({
    isCompany: Joi.boolean(),

    companyName: Joi.string().when('isCompany', {
        is: true,
        then: Joi.required(),
        otherwise: Joi.optional()
    })
});

Если:

{
    isCompany: true
}

Результат:

"companyName" is required

Обязательность нескольких полей

with()

Поле должно сопровождаться другим полем.

const schema = Joi.object({
    password: Joi.string(),
    repeatPassword: Joi.string()
}).with('password', 'repeatPassword');

Если есть password, должен быть и repeatPassword.


without()

Запрещает совместное присутствие.

const schema = Joi.object({
    accessToken: Joi.string(),
    password: Joi.string()
}).without('accessToken', 'password');

xor()

Требует присутствия только одного поля.

const schema = Joi.object({
    email: Joi.string(),
    phone: Joi.string()
}).xor('email', 'phone');

Допустимо:

{
    email: 'test@mail.com'
}

или:

{
    phone: '+77001234567'
}

Ошибка:

{}

и:

{
    email: 'test@mail.com',
    phone: '+77001234567'
}

or()

Требует хотя бы одно поле.

const schema = Joi.object({
    email: Joi.string(),
    phone: Joi.string(),
    telegram: Joi.string()
}).or('email', 'phone', 'telegram');

and()

Требует совместного присутствия всех полей.

const schema = Joi.object({
    startDate: Joi.date(),
    endDate: Joi.date()
}).and('startDate', 'endDate');

nand()

Запрещает совместное присутствие группы полей.

const schema = Joi.object({
    password: Joi.string(),
    oauth: Joi.boolean()
}).nand('password', 'oauth');

Обработка неизвестных ключей

По умолчанию лишние ключи запрещены.

const schema = Joi.object({
    username: Joi.string()
});

Проверка:

{
    username: 'alex',
    role: 'admin'
}

Результат:

"role" is not allowed

Разрешение неизвестных ключей

const schema = Joi.object({
    username: Joi.string()
}).unknown(true);

Теперь дополнительные поля допускаются.


Удаление неизвестных ключей

const schema = Joi.object({
    username: Joi.string()
}).prefs({
    stripUnknown: true
});

Проверка:

{
    username: 'alex',
    role: 'admin'
}

Результат:

{
    value: {
        username: 'alex'
    }
}

Массовая настройка обязательности

Иногда удобно сделать все поля обязательными автоматически.

const baseSchema = {
    username: Joi.string(),
    email: Joi.string(),
    password: Joi.string()
};

const schema = Joi.object(baseSchema)
    .fork(
        ['username', 'email', 'password'],
        field => field.required()
    );

Частичная валидация для PATCH-запросов

Для PATCH обычно используются необязательные поля.

const updateUserSchema = Joi.object({
    username: Joi.string(),
    email: Joi.string().email(),
    avatar: Joi.string()
}).min(1);

min(1) требует наличие хотя бы одного поля.


create и update схемы

Создание

const createSchema = Joi.object({
    username: Joi.string().required(),
    email: Joi.string().required(),
    password: Joi.string().required()
});

Обновление

const updateSchema = Joi.object({
    username: Joi.string(),
    email: Joi.string(),
    password: Joi.string()
}).min(1);

Типичные ошибки

Ошибка №1: путаница между null и undefined

Joi.string().required()

null не разрешён автоматически.


Ошибка №2: ожидание, что optional() разрешает null

Joi.string().optional()

Разрешает отсутствие поля, но не null.


Ошибка №3: пустая строка считается валидной

Joi.string().required()

Пустая строка вызовет ошибку.


Ошибка №4: required() внутри optional() объекта

const schema = Joi.object({
    profile: Joi.object({
        bio: Joi.string().required()
    }).optional()
});

Если profile отсутствует — ошибки нет.

Но если объект передан:

{
    profile: {}
}

ошибка появится:

"profile.bio" is required

Рекомендации по проектированию схем

Явно указывать обязательные поля

username: Joi.string().required()

Повышает читаемость схемы.


Не смешивать null и отсутствие значения

Лучше заранее определить:

  • поле может отсутствовать;
  • поле всегда существует, но может быть null.

Использовать отдельные схемы для create/update

Это упрощает поддержку API.


Использовать default() для системных значений

createdAt: Joi.date().default(Date.now)

Контролировать лишние поля

Для публичных API особенно важно:

stripUnknown: true

или:

unknown(false)

Позволяет защититься от лишних данных и неожиданных свойств объектов.