receta
Object

Validation

Validation functions ensure data integrity through runtime type checking and cleanup operations. These utilities help you validate unknown data, remove unwanted values, and ensure objects meet expected schemas.

Table of Contents

  1. validateShape
  2. stripUndefined
  3. compact
  4. When to Use Which
  5. Integration with Validation Module

validateShape

Runtime validation that checks if an object conforms to a schema, returning Result<T, ObjectError>.

Signature

Signature
function validateShape<T extends PlainObject>(
  obj: unknown,
  schema: ObjectSchema<T>
): Result<T, ObjectError>

type ObjectSchema<T extends PlainObject> = {
  readonly [K in keyof T]: Validator<T[K]> | ObjectSchema<any>
}

type Validator<T> = (value: unknown) => value is T

interface ObjectError {
  readonly code: 'PATH_NOT_FOUND' | 'INVALID_PATH' | 'VALIDATION_ERROR' | 'CIRCULAR_REFERENCE'
  readonly message: string
  readonly path?: ObjectPath
  readonly cause?: unknown
}

validateShape stops at the first failure, and the error it returns always has the code VALIDATION_ERROR.

Why validateShape?

// ❌ Without validation: runtime errors
function processConfig(config: unknown) {
  const host = config.database.host  // TypeError if config.database doesn't exist!
  const port = config.database.port  // TypeError!
  return { host, port }
}

// ✅ With validateShape: safe, typed
const configSchema = {
  database: {
    host: (v: unknown): v is string => typeof v === 'string',
    port: (v: unknown): v is number => typeof v === 'number'
  }
}

const result = validateShape(config, configSchema)
if (isOk(result)) {
  // Fields with a validator get its type. A nested schema's type is unknown,
  // so pass a type argument to validateShape when you need it.
  const { database } = result.value
  console.log(database)
}

Real-World: API Response Validation

import { map } from '@ontologyio/receta/result'
// GitHub API response validation
const githubUserSchema = {
  login: (v: unknown): v is string => typeof v === 'string',
  id: (v: unknown): v is number => typeof v === 'number',
  avatar_url: (v: unknown): v is string => typeof v === 'string' && v.startsWith('http'),
  public_repos: (v: unknown): v is number => typeof v === 'number' && v >= 0
}

const validateGitHubUser = (response: unknown) =>
  pipe(
    response,
    (r) => validateShape(r, githubUserSchema),
    map(user => ({
      username: user.login,
      id: user.id,
      avatar: user.avatar_url,
      repos: user.public_repos
    }))
  )

Real-World: Environment Variable Validation

const envSchema = {
  NODE_ENV: (v: unknown): v is string => ['development', 'production', 'test'].includes(v as string),
  PORT: (v: unknown): v is string => typeof v === 'string' && /^\d+$/.test(v),
  DATABASE_URL: (v: unknown): v is string => typeof v === 'string' && v.length > 0,
  API_KEY: (v: unknown): v is string => typeof v === 'string' && v.length === 32
}

const loadEnv = () => {
  const result = validateShape(process.env, envSchema)

  return match(result, {
    onOk: (env) => env,
    onErr: (error) => {
      console.error(`Invalid environment: ${error.message}`, error.path)
      process.exit(1)
    }
  })
}

Nested Schema Validation

// Validate nested structures
const userSchema = {
  id: (v: unknown): v is number => typeof v === 'number',
  profile: {
    name: (v: unknown): v is string => typeof v === 'string' && v.length > 0,
    email: (v: unknown): v is string => typeof v === 'string' && v.includes('@'),
    age: (v: unknown): v is number => typeof v === 'number' && v >= 0 && v <= 150
  },
  settings: {
    theme: (v: unknown): v is string => ['light', 'dark'].includes(v as string),
    notifications: (v: unknown): v is boolean => typeof v === 'boolean'
  }
}

const result = validateShape(unknownData, userSchema)
// Returns Err with path if nested validation fails:
// { code: 'VALIDATION_ERROR', path: ['profile', 'email'], message: 'Validation failed for key "email"' }

Integration with Predicate Module

import { isString, isNumber } from '@ontologyio/receta/predicate'

const isEmail = (v: unknown): v is string => isString(v) && v.includes('@')

const userSchema = {
  name: isString,
  email: isEmail,
  age: isNumber
}

validateShape(data, userSchema)

Custom Validators

// Build reusable validators
const isPositiveInt = (v: unknown): v is number =>
  typeof v === 'number' && Number.isInteger(v) && v > 0

const isNonEmptyString = (v: unknown): v is string =>
  typeof v === 'string' && v.trim().length > 0

const isValidUrl = (v: unknown): v is string =>
  typeof v === 'string' && /^https?:\/\/.+/.test(v)

const apiSchema = {
  endpoint: isValidUrl,
  timeout: isPositiveInt,
  apiKey: isNonEmptyString
}

Error Handling

const handleValidationError = (error: ObjectError) =>
  `Validation failed at ${error.path?.join('.')}: ${error.message}`

const result = validateShape(data, userSchema)
if (isErr(result)) {
  console.error(handleValidationError(result.error))
}

stripUndefined

Removes all properties with undefined values from an object (shallow operation).

Signature

Signature
function stripUndefined<T extends PlainObject>(obj: T): Partial<T>

Why stripUndefined?

// ❌ An undefined field is still a key: spreads and Object.keys see it
const payload = {
  name: 'Alice',
  email: undefined,
  phone: undefined
}

Object.keys(payload)
// => ['name', 'email', 'phone']

// ✅ Clean payload before sending
const cleanPayload = stripUndefined(payload)
// => { name: 'Alice' }

Real-World: Form Data Sanitization

import { flatMap } from '@ontologyio/receta/result'
// React form with optional fields
const FormComponent = () => {
  const [formData, setFormData] = useState({
    name: 'Alice',
    email: 'alice@example.com',
    phone: undefined,  // Optional field not filled
    company: undefined  // Optional field not filled
  })

  const handleSubmit = async () => {
    const cleanData = pipe(
      formData,
      stripUndefined,
      (data) => validateShape(data, formSchema),
      flatMap(saveToApi)
    )
  }
}

Real-World: Query String Building

// Build URL query params, omit undefined
const buildQueryString = (params: Record<string, unknown>) => {
  const clean = stripUndefined(params)
  return new URLSearchParams(clean as Record<string, string>).toString()
}

const url = buildQueryString({
  search: 'receta',
  category: undefined,  // Not set, should be omitted
  page: 1,
  sortBy: undefined  // Not set, should be omitted
})
// => "search=receta&page=1"

Real-World: Partial Updates

// Only send changed fields to API
const updateUser = (userId: string, changes: Partial<User>) => {
  const payload = stripUndefined(changes)

  // Only sends defined fields
  return fetch(`/api/users/${userId}`, {
    method: 'PATCH',
    body: JSON.stringify(payload)
  })
}

// Usage
updateUser('123', {
  name: 'Alice Smith',  // Changed
  email: undefined,     // Not changed, omit
  age: undefined        // Not changed, omit
})

Keeps Other Falsy Values

const data = {
  count: 0,           // Kept (falsy but not undefined)
  active: false,      // Kept (falsy but not undefined)
  name: '',           // Kept (falsy but not undefined)
  email: null,        // Kept (not undefined)
  phone: undefined    // Removed
}

stripUndefined(data)
// => { count: 0, active: false, name: '', email: null }

In Pipelines

// Complete sanitization pipeline
const sanitizeFormData = (formData: Record<string, unknown>) =>
  pipe(
    formData,
    stripUndefined,              // Remove undefined
    filterValues((v) => v !== ''),  // Remove empty strings
    mask(allowedFields),         // Only allowed fields
    validateShape(formSchema)    // Validate structure
  )

compact

Removes all nullish (null or undefined) values from an object (shallow operation).

Signature

Signature
function compact<T extends PlainObject>(obj: T): Partial<T>

Why compact?

// ❌ Nullish values can cause issues downstream
const userData = {
  name: 'Alice',
  email: null,      // User cleared email
  phone: undefined, // Never provided
  age: 30
}

// ✅ Remove all nullish values
const clean = compact(userData)
// => { name: 'Alice', age: 30 }

stripUndefined vs compact

const data = {
  a: undefined,
  b: null,
  c: 0,
  d: false,
  e: ''
}

// Keeps null
stripUndefined(data)
// => { b: null, c: 0, d: false, e: '' }

// Removes both null and undefined
compact(data)
// => { c: 0, d: false, e: '' }

Real-World: Database Query Building

// Build WHERE clause, skip nullish filters
const buildWhereClause = (filters: {
  status?: string | null
  category?: string | null
  minPrice?: number | null
}) => {
  const activeFilters = compact(filters)

  return Object.entries(activeFilters)
    .map(([key, value]) => `${key} = '${value}'`)
    .join(' AND ')
}

buildWhereClause({
  status: 'active',
  category: null,    // Skip this
  minPrice: undefined // Skip this
})
// => "status = 'active'"

Real-World: GraphQL Query Variables

// Remove nullish variables before sending GraphQL query
const queryUsers = (filters: UserFilters) => {
  const variables = compact(filters)

  return graphql`
    query Users($status: String, $role: String, $search: String) {
      users(status: $status, role: $role, search: $search) {
        id
        name
      }
    }
  `({
    variables  // Only includes non-nullish values
  })
}

Real-World: localStorage Cleanup

// Save only meaningful preferences
const savePreferences = (prefs: UserPreferences) => {
  const meaningful = pipe(
    prefs,
    compact,                    // Remove nullish
    filterValues((v) => v !== '') // Remove empty strings
  )

  localStorage.setItem('preferences', JSON.stringify(meaningful))
}

Preserves Zero, False, Empty String

// compact only removes nullish, not other falsy values
const data = {
  count: 0,        // ✅ Kept (valid number)
  enabled: false,  // ✅ Kept (valid boolean)
  title: '',       // ✅ Kept (valid string)
  notes: null,     // ❌ Removed (nullish)
  tags: undefined  // ❌ Removed (nullish)
}

compact(data)
// => { count: 0, enabled: false, title: '' }

In Pipelines

// Complete data cleanup
const prepareAnalytics = (rawData: RawAnalytics) =>
  pipe(
    rawData,
    compact,                      // Remove nullish
    (data) => flatten(data),      // Flatten for storage
    filterKeys((k) => !k.includes('_internal')) // Remove internal keys
  )

When to Use Which

Decision Tree

Need to validate or clean object?
│
├─ Need runtime type checking?
│  → Use validateShape() → Result<T, ObjectError>
│
├─ Have optional form fields?
│  │
│  ├─ Only remove undefined?
│  │  → Use stripUndefined()
│  │
│  └─ Remove both null and undefined?
│     → Use compact()
│
└─ Building API payload?
   └─ Use stripUndefined() + validateShape()

Comparison Table

FunctionRemovesReturnsUse Case
validateShape-Result<T, E>Type checking unknown data
stripUndefinedundefinedPartial<T>Form data, API payloads
compactnull + undefinedPartial<T>Query builders, cleanup

Common Patterns

import { flatMap, map, unwrapOr } from '@ontologyio/receta/result'
// Pattern 1: Form submission
pipe(
  formData,
  stripUndefined,
  validateShape(formSchema),
  flatMap(submitToApi)
)

// Pattern 2: Query building
pipe(
  filters,
  compact,
  buildWhereClause
)

// Pattern 3: API response cleanup
pipe(
  apiResponse,
  compact,
  validateShape(responseSchema),
  map(normalizeData)
)

// Pattern 4: Configuration loading
pipe(
  rawConfig,
  stripUndefined,
  validateShape(configSchema),
  unwrapOr(defaultConfig)
)

Integration with Validation Module

The Object module's validateShape is designed for structural validation (type checking), while the Validation module handles business logic validation (error accumulation).

When to Use Object.validateShape

// ✅ Type checking unknown API responses
const result = validateShape(apiResponse, userSchema)
// => Result<User, ObjectError> (fail-fast)

// ✅ Runtime type guards
const checked = validateShape(data, userSchema)
if (isOk(checked)) {
  // checked.value has the schema's shape
}

When to Use Validation Module

import { schema, validate, required, email, minLength, min } from '@ontologyio/receta/validation'

// ✅ Business rules with error accumulation (fail-slow)
const userFormSchema = {
  name: validate([required('Name is required'), minLength(3, 'Name too short')]),
  email: validate([required('Email is required'), email('Invalid email')]),
  age: min(18, 'Must be 18+')
}

// All errors at once
const result = schema(userFormSchema, { name: 'Al', email: 'nope', age: 17 })
// => Invalid([{ field: 'name', errors: ['Name too short'] }, { field: 'email', errors: ['Invalid email'] }, { field: 'age', errors: ['Must be 18+'] }])

Using Both Together

import { flatMap, fromResult } from '@ontologyio/receta/validation'

// Step 1: Structural validation (fail-fast)
const structuralCheck = validateShape(unknownData, typeSchema)

// Step 2: Business validation (fail-slow)
const businessCheck = pipe(
  fromResult(structuralCheck),
  flatMap(validateBusinessRules)
)

Next Steps

On this page