Trimmed strings

Валидация строк часто требует не только проверки типа, но и нормализации значения. Одна из наиболее распространённых операций — удаление пробелов в начале и конце строки. В JavaScript для этого используется метод trim(), однако ручное применение в каждом месте приводит к дублированию логики и ошибкам.

Библиотека Superstruct позволяет встроить очистку строк непосредственно в схему данных. Такой подход делает структуру данных самодостаточной: схема одновременно описывает тип, ограничения и правила преобразования.


Проблема «грязных» строк

Типичная пользовательская строка редко приходит в идеальном виде:

const username = '   admin   '

Без обработки возникают проблемы:

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

Например:

'admin' !== ' admin '

При использовании Superstruct нормализация может выполняться автоматически во время валидации.


Базовая схема строк

Простейшая строковая схема:

import { string } from 'superstruct'

const Username = string()

Проверка:

Username.is('alex') // true
Username.is(123)    // false

Однако такая схема не изменяет данные. Строка с пробелами останется без изменений.


Использование coerce для trim

В Superstruct преобразование значений выполняется через coerce.

Базовый синтаксис:

coerce(targetStruct, conditionStruct, transformer)

Параметры:

Параметр Назначение
targetStruct итоговая структура
conditionStruct условие применения
transformer функция преобразования

Trimmed string через coerce

Наиболее распространённая реализация:

import { coerce, string } from 'superstruct'

const TrimmedString = coerce(
  string(),
  string(),
  value => value.trim()
)

Теперь строка автоматически очищается:

TrimmedString.create('   hello   ')

Результат:

'hello'

Метод create и преобразование данных

Метод create() выполняет:

  1. преобразование;
  2. валидацию;
  3. возврат итогового значения.

Пример:

const result = TrimmedString.create('   test   ')

console.log(result)

Результат:

test

Если значение не соответствует схеме:

TrimmedString.create(42)

Возникнет ошибка валидации.


Почему conditionStruct обычно равен string()

Во многих примерах:

coerce(string(), string(), ...)

Обе структуры одинаковы.

Это означает:

  • преобразование выполняется только для строк;
  • остальные типы не трогаются.

Например:

const Trimmed = coerce(
  string(),
  string(),
  value => value.trim()
)

Поведение:

Значение Результат
' abc ' 'abc'
123 ошибка
null ошибка

Trim + дополнительные ограничения

Trimmed-строки почти всегда используются вместе с дополнительной проверкой.

Например, обязательное имя пользователя:

import {
  coerce,
  string,
  size,
} from 'superstruct'

const Username = coerce(
  size(string(), 3, 20),
  string(),
  value => value.trim()
)

Поведение:

Username.create('   alex   ')

Результат:

'alex'

Проверка длины выполняется уже после trim().


Важность порядка операций

Superstruct сначала выполняет coercion, затем validation.

Это особенно важно:

'   ab   '

После trim():

'ab'

Если минимальная длина равна 3:

size(string(), 3, 20)

валидация завершится ошибкой.


Trimmed email

Практический пример:

import {
  coerce,
  pattern,
  string,
} from 'superstruct'

const Email = coerce(
  pattern(string(), /^[^\s@]+@[^\s@]+\.[^\s@]+$/),
  string(),
  value => value.trim()
)

Использование:

Email.create('   admin@example.com   ')

Результат:

'admin@example.com'

Trimmed optional string

Иногда поле необязательно:

import {
  coerce,
  optional,
  string,
} from 'superstruct'

const OptionalTrimmed = optional(
  coerce(
    string(),
    string(),
    value => value.trim()
  )
)

Поведение:

OptionalTrimmed.create(undefined)

Результат:

undefined

А строка:

OptionalTrimmed.create('  hello  ')

превратится в:

'hello'

Trimmed nullable string

Nullable-поля допускают null.

import {
  coerce,
  nullable,
  string,
} from 'superstruct'

const NullableTrimmed = nullable(
  coerce(
    string(),
    string(),
    value => value.trim()
  )
)

Поведение:

NullableTrimmed.create(null)

Результат:

null

Преобразование пустой строки в undefined

Частая задача в формах:

'   '

После очистки:

''

Нередко требуется заменить это значение на undefined.

Пример:

import {
  coerce,
  optional,
  string,
  unknown,
} from 'superstruct'

const EmptyToUndefined = coerce(
  optional(string()),
  unknown(),
  value => {
    if (typeof value !== 'string') {
      return value
    }

    const trimmed = value.trim()

    return trimmed === ''
      ? undefined
      : trimmed
  }
)

Почему здесь используется unknown()

В предыдущих примерах условием было:

string()

Но теперь требуется принимать любые значения:

  • строки;
  • undefined;
  • null;
  • другие типы.

Поэтому используется:

unknown()

Преобразователь самостоятельно решает, что делать дальше.


Trimmed строки внутри object

Наиболее типичный сценарий — схемы объектов.

import {
  object,
  string,
  coerce,
} from 'superstruct'

const TrimmedString = coerce(
  string(),
  string(),
  value => value.trim()
)

const User = object({
  username: TrimmedString,
  email: TrimmedString,
})

Использование:

const user = User.create({
  username: '   alex   ',
  email: '   alex@example.com   ',
})

Результат:

{
  username: 'alex',
  email: 'alex@example.com'
}

Переиспользуемые структуры

Обычно trimmed-структуры выносятся отдельно:

// structs/trimmed.js

import {
  coerce,
  string,
} from 'superstruct'

export const TrimmedString = coerce(
  string(),
  string(),
  value => value.trim()
)

После этого:

import { TrimmedString } from './structs/trimmed.js'

Создание специализированных trimmed-типов

Базовую структуру удобно расширять.

Trimmed username

import {
  size,
} from 'superstruct'

const Username = size(
  TrimmedString,
  3,
  20
)

Trimmed title

const Title = size(
  TrimmedString,
  1,
  120
)

Trimmed comment

const Comment = size(
  TrimmedString,
  1,
  5000
)

Trimmed строки и default values

В Superstruct можно комбинировать coercion с default-значениями.

Пример:

import {
  defaulted,
} from 'superstruct'

const DisplayName = defaulted(
  TrimmedString,
  'Anonymous'
)

Поведение:

DisplayName.create(undefined)

Результат:

'Anonymous'

Trimmed массивы строк

Массивы особенно выигрывают от автоматической очистки.

import {
  array,
} from 'superstruct'

const Tags = array(TrimmedString)

Использование:

Tags.create([
  '  js  ',
  '  node  ',
  '  api  ',
])

Результат:

['js', 'node', 'api']

Trimmed строки и union

Возможна комбинация нескольких типов.

import {
  union,
  number,
} from 'superstruct'

const Id = union([
  TrimmedString,
  number(),
])

Trimmed строки с refine

refine добавляет дополнительную логику проверки.

Пример:

import {
  refine,
} from 'superstruct'

const SafeUsername = refine(
  TrimmedString,
  'SafeUsername',
  value => {
    return !value.includes(' ')
  }
)

Проверка:

SafeUsername.create('   admin   ')

Успешно:

'admin'

Но:

SafeUsername.create('   admin user   ')

вызовет ошибку.


Trimmed строки и pattern

Частое сочетание:

import {
  pattern,
} from 'superstruct'

const Slug = pattern(
  TrimmedString,
  /^[a-z0-9-]+$/
)

Использование:

Slug.create('   my-post   ')

Результат:

'my-post'

Создание универсального helper

При большом количестве схем полезно создать фабрику.

import {
  coerce,
  string,
} from 'superstruct'

function trimmed(struct = string()) {
  return coerce(
    struct,
    string(),
    value => value.trim()
  )
}

Использование:

const Username = trimmed(
  size(string(), 3, 20)
)

Trimmed lowercase string

Комбинация нескольких преобразований:

const LowercaseEmail = coerce(
  string(),
  string(),
  value => value
    .trim()
    .toLowerCase()
)

Результат:

LowercaseEmail.create('   ADMIN@SITE.COM   ')

Вернёт:

'admin@site.com'

Нормализация телефонных номеров

Trim часто является только первым этапом.

const Phone = coerce(
  string(),
  string(),
  value => {
    return value
      .trim()
      .replace(/\s+/g, '')
  }
)

Пример:

Phone.create('  +7 777 123 45 67  ')

Результат:

'+77771234567'

Trimmed строки в API-валидации

Типичная схема запроса:

const CreatePostRequest = object({
  title: Title,
  content: Comment,
  tags: array(TrimmedString),
})

Входные данные:

{
  title: '   Hello World   ',
  content: '   Text   ',
  tags: ['  js ', ' node ']
}

После обработки:

{
  title: 'Hello World',
  content: 'Text',
  tags: ['js', 'node']
}

Отличие trim от sanitize middleware

Очистка через middleware:

req.body.name = req.body.name.trim()

имеет недостатки:

  • логика разбросана по проекту;
  • возможны пропуски;
  • сложнее тестирование;
  • невозможно переиспользование схем.

Подход Superstruct делает нормализацию частью контракта данных.


Ошибки при использовании trimmed-структур

Отсутствие create()

Метод:

assert(data, Struct)

не возвращает преобразованное значение.

Для coercion нужен именно:

create(data, Struct)

или:

Struct.create(...)

Trim после проверки длины

Неправильно:

size(string(), 3, 20)

а затем отдельно:

value.trim()

Проверка уже выполнена на исходной строке.


Игнорирование пустых строк

После trim:

'   '

становится:

''

Это всё ещё строка. Если пустые значения запрещены, требуется дополнительная проверка.


Проверка непустой trimmed-строки

Пример:

import {
  refine,
} from 'superstruct'

const NonEmptyString = refine(
  TrimmedString,
  'NonEmptyString',
  value => value.length > 0
)

Использование:

NonEmptyString.create('   ')

Завершится ошибкой.


Комбинация size и trim

Более компактный вариант:

const RequiredText = size(
  TrimmedString,
  1,
  Infinity
)

После trim строка должна содержать минимум один символ.


Trimmed строки и TypeScript

Superstruct корректно выводит типы.

const Name = TrimmedString

Тип значения:

string

При использовании Infer:

import { Infer } from 'superstruct'

type Name = Infer<typeof TrimmedString>

Результат:

type Name = string

Практический паттерн для production-проектов

Часто создаётся набор базовых примитивов:

export const TrimmedString
export const NonEmptyString
export const Email
export const Username
export const Slug
export const Title

После этого все схемы строятся только из готовых компонентов.

Пример:

const Article = object({
  title: Title,
  slug: Slug,
  author: Username,
})

Такой подход:

  • уменьшает дублирование;
  • стандартизирует валидацию;
  • упрощает поддержку;
  • делает поведение предсказуемым;
  • централизует правила нормализации.