0.1.4 • Published 11 months ago

@railway-ts/validation v0.1.4

Weekly downloads
-
License
MIT
Repository
github
Last release
11 months ago

@railway-ts/validation

npm version Build Status License: MIT Bundle Size TypeScript Coverage

A comprehensive validation library built on top of @railway-ts/core, providing a functional programming approach to validation with composable validators and type-safe error handling.

Overview

@railway-ts/validation provides a robust, type-safe approach to data validation in TypeScript applications. Using functional programming principles, it allows you to compose validators, transform data during validation, and handle validation errors in a consistent, predictable way.

The library is built on top of @railway-ts/core, leveraging its Result type to handle validation outcomes without exceptions, making error handling more explicit and manageable.

Features

  • Composable validators: Chain validators together to create complex validation rules
  • Path-aware error messages: Detailed validation errors with exact paths to invalid fields
  • Type inference: Automatic type inference for validated data structures
  • Data transformation: Transform input data during validation (parse strings to numbers, etc.)
  • Specialized validators: Built-in validators for common data types (strings, numbers, dates, arrays, objects)
  • Form-friendly: Easy integration with form libraries
  • Zero dependencies: No external runtime dependencies beyond @railway-ts/core
  • Tree-shakable: Import only what you need
  • TypeScript-first: Built with TypeScript for a great developer experience

Installation

# npm
npm install @railway-ts/validation

# yarn
yarn add @railway-ts/validation

# pnpm
pnpm add @railway-ts/validation

# bun
bun add @railway-ts/validation

Basic Usage

Here's a simple example to get you started:

import {
  object,
  string,
  required,
  email,
  minLength,
  parseNumber,
  composeRight,
  validate,
  formatErrors,
} from "@railway-ts/validation";
import { isErr } from "@railway-ts/core";

// Define a schema
const userSchema = object({
  username: required(composeRight(string(), minLength(3))),
  email: required(composeRight(string(), email())),
  age: required(parseNumber()),
});

// Validate data
const userData = {
  username: "john_doe",
  email: "john@example.com",
  age: "25", // Will be parsed to a number
};

const result = validate(userData, userSchema);

if (isErr(result)) {
  // Handle validation errors
  console.error(formatErrors(result.error));
} else {
  // Use the validated data
  console.log(result.value); // Typed as { username: string; email: string; age: number }
}

API Reference

Core Types

// Represents a validation error with path information
type ValidationError = {
  path: string[];  // Path to the invalid field
  message: string; // Error message
};

// A validator function that checks if a value meets certain criteria
type Validator<I, O = I> = (value: I, path?: string[]) => Result<O, ValidationError[]>;

// Object schema definition
type Schema<T = Record<string, unknown>> = {
  [K in keyof T]: Validator<unknown, T[K]>;
};

// Infers the output type of a validator after processing
type InferSchemaType<V> = ...

Core Validators

FunctionDescription
object<T>(schema: Schema<T>, options?: { strict?: boolean })Creates a validator for objects based on a schema
required<I, O>(validator: Validator<I, O>, message?: string)Makes a field required (not null or undefined)
optional<I, O>(validator: Validator<I, O>)Makes a field optional (can be null or undefined)
// Example: Object schema with required and optional fields
const userSchema = object({
  username: required(string()), // Required field
  email: required(email()), // Required with validation
  profile: optional(
    object({
      // Optional nested object
      bio: optional(string()),
      age: optional(parseNumber()),
    }),
  ),
});

// Non-strict mode (allows extra fields)
const lenientSchema = object({ name: required(string()) }, { strict: false });

String Validators

FunctionDescription
string(message?: string)Ensures a value is a string
minLength(min: number, message?: string)Ensures a string's length is at least a minimum value
maxLength(max: number, message?: string)Ensures a string's length is at most a maximum value
pattern(regex: RegExp, message?: string)Ensures a string matches a regular expression pattern
nonEmpty(message?: string)Ensures a string is not empty after trimming whitespace
email(message?: string)Ensures a string is formatted as a valid email address
// String validation examples
const nameValidator = composeRight(
  string("Must be a string"),
  nonEmpty("Name cannot be empty"),
  minLength(2, "Name must be at least 2 characters"),
);

const bioValidator = composeRight(string(), maxLength(500, "Bio cannot exceed 500 characters"));

const zipCodeValidator = composeRight(string(), pattern(/^\d{5}$/, "ZIP code must be 5 digits"));

const emailValidator = composeRight(string(), nonEmpty(), email("Please enter a valid email address"));

Number Validators

FunctionDescription
number(message?: string)Ensures a value is a number and not NaN
min(value: number, message?: string)Ensures a number is greater than or equal to a minimum value
max(value: number, message?: string)Ensures a number is less than or equal to a maximum value
between(min: number, max: number, message?: string)Ensures a number is between a minimum and maximum value (inclusive)
integer(message?: string)Ensures a number is an integer (no decimal places)
positive(message?: string)Ensures a number is positive (greater than zero)
negative(message?: string)Ensures a number is negative (less than zero)
nonZero(message?: string)Ensures a number is not zero
divisibleBy(divisor: number, message?: string)Ensures a number is divisible by a specific divisor
precision(maxDecimalPlaces: number, message?: string)Ensures a number has at most the specified number of decimal places
// Number validation examples
const ageValidator = composeRight(
  number(),
  integer("Age must be a whole number"),
  min(18, "Must be at least 18 years old"),
);

const ratingValidator = composeRight(number(), between(1, 5, "Rating must be between 1 and 5"));

const priceValidator = composeRight(
  number(),
  positive("Price must be positive"),
  precision(2, "Price can have at most 2 decimal places"),
);

const evenValidator = composeRight(number(), integer(), divisibleBy(2, "Must be an even number"));

Date Validators

FunctionDescription
dateRange(min: Date, max: Date, message?: string)Ensures a Date is within a specified range
pastDate(message?: string)Ensures a Date is in the past (before the current date and time)
futureDate(message?: string)Ensures a Date is in the future (after the current date and time)
todayOrFuture(message?: string)Ensures a Date is either today or in the future
// Date validation examples
const birthDateValidator = composeRight(parseDate(), pastDate("Birth date must be in the past"));

const appointmentValidator = composeRight(parseDate(), futureDate("Appointment must be in the future"));

const eventDateValidator = composeRight(
  parseDate(),
  dateRange(new Date("2023-01-01"), new Date("2023-12-31"), "Event must be scheduled in 2023"),
);

const deliveryDateValidator = composeRight(parseDate(), todayOrFuture("Delivery date must be today or later"));

Boolean Validators

FunctionDescription
boolean(message?: string)Ensures a value is a boolean
mustBeChecked(message?: string)Ensures a boolean value is true (commonly used for checkboxes)
isFalse(message?: string)Ensures a boolean value is false
matches(expected: boolean, message?: string)Ensures a boolean value matches the expected value
isNullable(message?: string)Ensures a value is either a boolean or null
// Boolean validation examples
const termsValidator = composeRight(parseBool(), mustBeChecked("You must accept the terms and conditions"));

const featureValidator = composeRight(boolean(), matches(true, "This feature must be enabled"));

const experimentalFeatureValidator = composeRight(
  boolean(),
  isFalse("Experimental features must be disabled in production"),
);

const optionalConsentValidator = isNullable("Value must be a boolean or null");

Array Validators

FunctionDescription
array<I, O>(itemValidator: Validator<I, O>)Creates a validator for arrays where each item is validated
oneOf<T>(allowedValues: T[], message?: string)Ensures a value is one of the allowed values
stringEnum<T extends string>(allowedValues: T[], message?: string)Ensures a value is a string and one of the allowed enum values
numberArray(message?: string)Creates a validator for arrays of numbers
selectionArray<T extends string>(options: T[], message?: string)Creates a validator for arrays of enum values
// Array validation examples
const tagsValidator = array(string());

const userRolesValidator = array(stringEnum(["admin", "editor", "viewer"]));

const statusValidator = oneOf(
  ["pending", "approved", "rejected"],
  "Status must be one of: pending, approved, rejected",
);

const scoresValidator = numberArray("Each score must be a valid number");

const contactTypesValidator = selectionArray(
  ["email", "phone", "mail"],
  "Contact type must be one of: email, phone, mail",
);

Union Validators

FunctionDescription
union<I, O>(validators: Array<Validator<I, O>>,options?: { collectAllErrors?: boolean })Creates a validator that accepts values matching any provided validator
discriminatedUnion<T>(discriminantField: string,validatorMap: Record<string, Validator>)Creates a validator that selects schemas based on a discriminant field
withCommonFields<C, S>(commonSchema: Validator<unknown, C>,specificSchema: Validator<unknown, S>)Combines shared fields with variant-specific fields in a union validator
// Basic union example - allowing different types
const valueValidator = union([stringValidator, numberValidator, booleanValidator]);

// Discriminated union example for message types
const textMessageSchema = object({
  type: stringEnum(["text"]),
  content: required(string()),
});

const imageMessageSchema = object({
  type: stringEnum(["image"]),
  url: required(string()),
  caption: optional(string()),
});

// Create a discriminated union validator using the 'type' field
const messageValidator = discriminatedUnion<Message>("type", {
  text: textMessageSchema,
  image: imageMessageSchema,
});

// Common fields with discriminated union
const commonFieldsSchema = object({
  id: required(string()),
  createdAt: required(parseDate()),
});

const completeMessageSchema = withCommonFields(commonFieldsSchema, messageValidator);

// Usage
const result = validate(messageData, completeMessageSchema);

Advanced Union Example

// Define possible maneuver types
type ManeuverType = "manual_burn" | "inclination_change" | "right_ascension_change";

// Define schemas for each maneuver type
const manualBurnSchema = object({
  type: stringEnum<ManeuverType>(["manual_burn"]),
  epoch: parseDate(),
  Radial: composeRight(parseNumber(), between(-1000, 1000)),
  InTrack: composeRight(parseNumber(), between(-1000, 1000)),
  CrossTrack: composeRight(parseNumber(), between(-1000, 1000)),
  Engine: stringEnum(["IMP", "RCS", "TCM"]),
});

const inclinationChangeSchema = object({
  type: stringEnum<ManeuverType>(["inclination_change"]),
  epoch: parseDate(),
  Inclination: composeRight(parseNumber(), between(0, 180)),
  Node: stringEnum(["Ascending", "Descending", "Optimal", "Next"]),
  Engine: stringEnum(["IMP", "RCS", "TCM"]),
});

// Create a discriminated union
const maneuverSchema = discriminatedUnion<Maneuver>("type", {
  manual_burn: manualBurnSchema,
  inclination_change: inclinationChangeSchema,
});

// Infer the type from the schema
type Maneuver = InferSchemaType<typeof maneuverSchema>;

Parser Validators

FunctionDescription
parseNumber(message?: string)Parses input into a number
parseDate(message?: string)Parses input into a Date object
parseBool(message?: string)Parses input into a boolean
parseString(message?: string)Parses input into a string
parseJSON(message?: string)Parses JSON strings into objects
parseInteger(message?: string)Parses input into an integer
parseISODate(message?: string)Parses ISO format date strings
parseURL(message?: string)Parses string URLs into URL objects
parsePhoneNumber(pattern?: RegExp, message?: string)Parses and validates phone numbers
// Parser examples
const ageParser = parseNumber("Age must be a valid number");

const birthdateParser = parseDate("Birthdate must be a valid date");

const consentParser = parseBool("Please indicate yes or no");

const configParser = parseJSON("Configuration must be valid JSON");

// Parsing from form data (strings to typed values)
const userSchema = object({
  name: required(string()),
  age: required(parseNumber("Age must be a valid number")),
  birthdate: required(parseISODate("Date must be in YYYY-MM-DD format")),
  hasConsented: required(parseBool("Please indicate yes or no")),
  website: optional(parseURL("Please enter a valid URL")),
  phone: optional(parsePhoneNumber()),
});

Utility Functions

FunctionDescription
composeRight(...validators: Validator[])Combines multiple validators into a single validator
validate<T>(value: unknown, validator: Validator<unknown, T>)Applies a validator to a value
formatErrors(errors: ValidationError[])Formats validation errors into a user-friendly object
// Compose multiple validators
const passwordValidator = composeRight(
  string(),
  nonEmpty(),
  minLength(8),
  pattern(/[A-Z]/, "Password must contain at least one uppercase letter"),
  pattern(/[0-9]/, "Password must contain at least one number"),
);

// Validate a value with a validator
const userSchema = object({
  username: required(composeRight(string(), minLength(3))),
  password: required(passwordValidator),
});

const result = validate(
  {
    username: "john",
    password: "weak",
  },
  userSchema,
);

// Format errors for display
if (isErr(result)) {
  const formattedErrors = formatErrors(result.error);
  console.log(formattedErrors);
  // Output: { 'password': 'Password must be at least 8 characters' }
}

Tree-Shaking and Module Structure

@railway-ts/validation is designed to be tree-shakable. Import only what you need:

// Import everything
import { string, number, required, parseNumber, validate } from "@railway-ts/validation";

// Using named imports for specific functions
import { object } from "@railway-ts/validation/core";
import { string, email } from "@railway-ts/validation/string";
import { parseNumber } from "@railway-ts/validation/parsers";

Comparison with Similar Libraries

@railway-ts/validation vs Zod

  • Error Handling: @railway-ts/validation uses the Result type from @railway-ts/core for predictable error handling; Zod uses exceptions
  • Composability: @railway-ts/validation provides standalone utility functions for composition; Zod uses method chaining
  • Integration: @railway-ts/validation integrates naturally with other @railway-ts libraries; Zod is standalone
  • Bundle Size: @railway-ts/validation is generally smaller when tree-shaken
  • Philosophy: @railway-ts/validation embraces functional programming and railway-oriented programming; Zod is more object-oriented

@railway-ts/validation vs Yup

  • Type Safety: @railway-ts/validation offers stronger TypeScript integration with better type inference
  • Error Structure: @railway-ts/validation provides structured errors with clear paths; Yup's errors can be harder to work with
  • Performance: @railway-ts/validation is designed for efficiency with simpler abstractions
  • API Design: @railway-ts/validation uses function composition; Yup uses method chaining like Zod
  • Functional Approach: @railway-ts/validation embraces functional programming principles; Yup is more imperative

@railway-ts/validation vs joi

  • TypeScript Support: @railway-ts/validation is built for TypeScript from the ground up; joi has limited TypeScript support
  • Bundle Size: @railway-ts/validation is much lighter than joi
  • Browser Support: @railway-ts/validation works well in both Node.js and browsers; joi is more Node.js focused
  • API Style: @railway-ts/validation uses functional composition; joi uses fluent method chaining
  • Learning Curve: @railway-ts/validation may be easier to learn for developers familiar with functional programming

Advanced Examples

Type Inference and Nested Objects

import {
  object,
  string,
  required,
  composeRight,
  email,
  minLength,
  parseNumber,
  min,
  pattern,
  InferSchemaType,
} from "@railway-ts/validation";

// Define nested schemas
const addressSchema = object({
  street: required(string()),
  city: required(string()),
  zipCode: required(pattern(/^\d{5}$/)),
});

// Define main schema with nested objects
const userSchema = object({
  username: required(composeRight(string(), minLength(3))),
  email: required(email()),
  age: required(composeRight(parseNumber(), min(18))),
  address: required(addressSchema),
});

// TypeScript automatically infers the correct type
type User = InferSchemaType<typeof userSchema>;
// Equivalent to:
// type User = {
//   username: string;
//   email: string;
//   age: number;
//   address: {
//     street: string;
//     city: string;
//     zipCode: string;
//   }
// }

Custom Validators

import { object, string, required, composeRight, validate, formatErrors } from "@railway-ts/validation";
import { err, isOk, ok } from "@railway-ts/core";

// Create a custom password validator
const password = (message = "Password must contain letters and numbers") => {
  return (value: string, path: string[] = []) => {
    const hasLetters = /[a-zA-Z]/.test(value);
    const hasNumbers = /[0-9]/.test(value);

    if (!hasLetters || !hasNumbers) {
      return err([{ path, message }]);
    }

    return ok(value);
  };
};

// Use it in a schema
const loginSchema = object({
  username: required(string()),
  password: required(composeRight(string(), minLength(8, "Password must be at least 8 characters"), password())),
});

// Validate data
const result = validate(
  {
    username: "user123",
    password: "onlyletters",
  },
  loginSchema,
);

if (isOk(result)) {
  authenticateUser(result.value);
} else {
  displayErrors(formatErrors(result.error));
  // Output: { password: 'Password must contain letters and numbers' }
}

Form Validation with Password Confirmation

import { object, string, required, composeRight, validate, minLength, nonEmpty } from "@railway-ts/validation";
import { err, isOk, matchResult } from "@railway-ts/core";

// Define the form schema
const signupSchema = object({
  username: required(composeRight(string(), nonEmpty())),
  email: required(email()),
  password: required(composeRight(string(), minLength(8, "Password must be at least 8 characters"))),
  confirmPassword: required(string()),
});

// Custom validator for password matching
function validatePasswords(data: unknown) {
  const result = validate(data, signupSchema);

  if (isOk(result)) {
    const { password, confirmPassword } = result.value;

    if (password !== confirmPassword) {
      return err([
        {
          path: ["confirmPassword"],
          message: "Passwords do not match",
        },
      ]);
    }

    return ok(result.value);
  }

  return result;
}

// Usage
function handleSignup(formData) {
  const result = validatePasswords(formData);

  return matchResult(result, {
    ok: (data) => createUser(data),
    err: (errors) => ({
      success: false,
      errors: formatErrors(errors),
    }),
  });
}

Async Validation with API Checks

import { object, string, required, validate, formatErrors } from "@railway-ts/validation";
import { err, fromPromise, flatMapResult, isOk, ok } from "@railway-ts/core";

// Define the basic schema
const usernameSchema = object({
  username: required(composeRight(string(), minLength(3, "Username must be at least 3 characters"))),
});

// Async validator that checks username availability
async function validateUsernameAvailability(formData: unknown) {
  // First validate the basic constraints
  const result = validate(formData, usernameSchema);

  if (isOk(result)) {
    const { username } = result.value;

    // Call API to check availability
    const response = await fromPromise(fetch(`/api/check-username?username=${username}`));

    return flatMapResult(response, async (res) => {
      const data = await res.json();

      if (data.available) {
        return ok(result.value);
      } else {
        return err([
          {
            path: ["username"],
            message: "Username already taken",
          },
        ]);
      }
    });
  }

  return result;
}

// Usage in async context
async function handleUsernameSubmission(formData) {
  const result = await validateUsernameAvailability(formData);

  return matchResult(result, {
    ok: (data) => ({ success: true, data }),
    err: (errors) => ({ success: false, errors: formatErrors(errors) }),
  });
}

License

MIT

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.


Built on @railway-ts/core and inspired by functional programming concepts from languages like Rust, TypeScript, and Haskell.

0.1.4

11 months ago

0.1.3

11 months ago

0.1.2

11 months ago

0.1.1

12 months ago

0.1.0

12 months ago