Skip to content

Validation Approach

Language: JavaScript

Validation is the rule that keeps bad data out of the system. In this framework, every entity validates its own input in its own [entity].validation.js module - no third-party library, no decorators, no schema definitions in YAML. This document explains why, how the validation function is shaped, and where it sits in the request flow.

Every validation function follows the same shape:

javascript
// src/model/[entity]/[entity].validation.js
const CONFIG = require('./[entity].config');
const ERRORS = require('./[entity].errors');

module.exports = {
  validateCreate: function (name, email) {
    const errors = [];
    if (!name || typeof name !== 'string') errors.push(ERRORS.NAME_REQUIRED);
    if (name && name.length > CONFIG.NAME_MAX_LENGTH) errors.push(ERRORS.NAME_TOO_LONG);
    if (!email || typeof email !== 'string') errors.push(ERRORS.EMAIL_REQUIRED);
    return errors.length > 0 ? errors : false;
  }
};
  • Returns false on success (no errors)
  • Returns an array of error objects on failure. Each shaped { code, message, status } from [entity].errors.js
  • The caller checks if (validation_errors); truthy means errors exist

On This Page


Philosophy

  • All validation lives in the model layer ([entity].validation.js)
  • Validation is pure and IO-free - it runs unchanged on server and client
  • No third-party validation library is used - validation is hand-written against config rules
  • This keeps the framework dependency-free and the validation co-located with domain rules

How Validation Works

  1. [entity].config.js defines constraints: lengths, regex, enums, limits
  2. [entity].validation.js checks input against config, returns errors from [entity].errors.js
  3. [entity].errors.js provides a stable error catalog: { code, message, status }

Return Convention

  • Success: false (no errors)
  • Failure: Error[] (array of error objects from [entity].errors.js, each shaped { code, message, status })

This convention is consistent across all validation functions. Always check with if (result) - truthy means errors exist.

Domain validation errors are user-facing: the message field is intended to be shown to the end user (e.g. "Email address format is invalid"). This is distinct from helper-module errors and from programmer errors. See error-handling.md for the full three-category model and how the controller forwards these errors via Lib.Functions.errorResponse.


Validation Patterns

Simple Entity (e.g., User)

javascript
// Validate flat fields
SurveyValidation.validateCreate(title, description, questions, rules);

Nested Entity (e.g., Survey → Questions → Options)

javascript
// Top-level validates survey fields
// Then iterates and validates each question
// Then iterates and validates each option within choice questions
// Then validates cross-reference rules against collected question IDs

Type-Dependent Validation

  • Choice questions (single_choice, multi_choice) MUST have options
  • Non-choice questions (text, number, scale, date) must NOT have options
  • Scale questions validate constraints.min and constraints.max

Cross-Reference Validation

  • Rules reference source_question_id and target_question_id
  • Both must exist in the survey's question list
  • Self-reference (source === target) is not allowed
  • Operator and action must be from the allowed enum

Use Utils Type-Check Primitives

Hand-written validation does not mean hand-written type checks. helper-utils is the foundation module every other module already injects, and it owns the type-check primitives. Type guards call those primitives; they never re-derive the check with a raw typeof.

The reason is correctness before consistency. Two of the primitives are strictly stronger than the typeof expression they replace, and the raw form silently admits a value the validator was written to reject:

Raw checkUtils primitiveWhy the primitive is not merely shorter
typeof arg !== 'number'!Lib.Utils.isNumber(arg)typeof NaN is 'number', so the raw form accepts NaN. isNumber rejects it
typeof arg === 'object' && arg !== nullLib.Utils.isObject(arg)typeof null is 'object', so the raw form needs a second clause that is easy to omit
typeof arg !== 'function'!Lib.Utils.isFunction(arg)Same predicate; called for uniformity so every type guard in the file reads alike
typeof arg !== 'string'!Lib.Utils.isString(arg)Same predicate; called for uniformity
typeof arg !== 'boolean'!Lib.Utils.isBoolean(arg)Same predicate; called for uniformity
arg == nullLib.Utils.isNullOrUndefined(arg)Same predicate; names the intent instead of relying on loose equality

Range and sign checks stay inline next to the primitive, because they are domain rules rather than type questions: if (!Lib.Utils.isNumber(ms) || ms <= 0).

Scope

The rule covers both validation surfaces of a module, not just the companion file:

  • [module].validators.js - load-time config assertions and per-call options assertions
  • [module].js - inline guards inside public functions that return an error envelope rather than throwing

A module that reaches for Lib.Utils.isNullOrUndefined in one guard and a raw typeof in the next guard of the same function is the failure this rule exists to prevent. Mixed forms inside one module are a consistency violation, and the stronger primitives make them a correctness one.

Not a Type Guard: Argument-Shape Dispatch

Raw typeof stays correct where the question is which overload the caller used, not whether a value is valid:

javascript
start: function (key, options) {

  // Normalize arguments: key is optional
  if (typeof key === 'object' && key !== null) {
    options = key;
    key = 'default';
  }

This is dispatch on argument shape. It rejects nothing and produces no error, so no primitive applies. The same holds for duck-typing a host-supplied collaborator (typeof source.subscribe === 'function'), where the test is whether a capability is present rather than whether an input is well formed.


Validation in the Request Flow

Interface (Express/Lambda)
  → Controller extracts raw input from request
  → Controller calls Model.validation.validateCreate(explicit, params)
  → If errors: Controller returns errorResponse(errors[0])
  → If valid: Controller builds Data object with Model.data.create(explicit, params)
  → Controller delegates to Service with validated Data object
  • Controller is responsible for calling validation
  • Model owns the validation logic
  • Service trusts that input is already validated (receives Data objects only)

Further Reading

Released under the MIT License.