Skip to content

Latest commit

 

History

History
218 lines (188 loc) · 5.77 KB

File metadata and controls

218 lines (188 loc) · 5.77 KB
id schema-form-async-validation
title Async Validation (Username Availability)
category form-validation
skillLevel intermediate
tags
schema
form
async
validation
api-check
debounce
lessonOrder 2
rule
description
Async Validation (Username Availability) using Schema.
summary Some validation requires checking a server: is this username available? Is this email already registered? Standard schema validation is synchronous—you can't check a database or API. You need to...

Problem

Some validation requires checking a server: is this username available? Is this email already registered? Standard schema validation is synchronous—you can't check a database or API. You need to validate form fields asynchronously after sync validation passes, then combine results.

Solution

import { Effect, Schema, Schedule } from "effect"

// 1. Sync validation: format only
const Username = Schema.String.pipe(
  Schema.minLength(3),
  Schema.maxLength(20),
  Schema.pattern(/^[a-zA-Z0-9_-]+$/)
)

type Username = typeof Username.Type

// 2. Async check: database lookup
const checkUsernameAvailable = (
  username: Username
): Effect.Effect<boolean, Error> =>
  Effect.gen(function* () {
    // Simulate API call to check availability
    const response = yield* Effect.tryPromise({
      try: () =>
        fetch(
          `/api/check-username?username=${username}`
        ).then((r) => r.json()),
      catch: (error) => new Error(`API failed: ${error}`),
    })

    const available = response.available === true

    if (!available) {
      return yield* Effect.fail(
        new Error(`Username "${username}" is taken`)
      )
    }

    return available
  })

// 3. Email uniqueness check
const checkEmailAvailable = (email: string) =>
  Effect.gen(function* () {
    const response = yield* Effect.tryPromise({
      try: () =>
        fetch(`/api/check-email?email=${email}`).then(
          (r) => r.json()
        ),
      catch: (error) => new Error(`API failed: ${error}`),
    })

    if (!response.available) {
      return yield* Effect.fail(
        new Error(
          `Email "${email}" is already registered`
        )
      )
    }

    return true
  })

// 4. Combined validation: sync then async
const validateSignUp = (input: unknown) =>
  Effect.gen(function* () {
    // Step 1: Sync validation
    const syncData = yield* Schema.decodeUnknown(
      Schema.Struct({
        username: Username,
        email: Schema.String.pipe(
          Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
        ),
        password: Schema.String.pipe(
          Schema.minLength(8)
        ),
      })
    )(input).pipe(
      Effect.mapError((error) => ({
        _tag: "SyncError" as const,
        message: `Invalid input: ${error.message}`,
      }))
    )

    // Step 2: Async validation (in parallel)
    const [usernameOk, emailOk] = yield* Effect.all([
      checkUsernameAvailable(syncData.username).pipe(
        Effect.mapError((error) => ({
          _tag: "UsernameError" as const,
          message: error.message,
        }))
      ),
      checkEmailAvailable(syncData.email).pipe(
        Effect.mapError((error) => ({
          _tag: "EmailError" as const,
          message: error.message,
        }))
      ),
    ])

    return { ...syncData, usernameOk, emailOk }
  })

// 5. With debounce for real-time UI checking
const validateUsernameRealtime = (
  username: string
): Effect.Effect<boolean, Error> =>
  Effect.gen(function* () {
    // Sync validation first
    yield* Schema.decodeUnknown(Username)(
      username
    ).pipe(
      Effect.mapError((error) =>
        new Error(`Invalid format: ${error.message}`)
      )
    )

    // Debounce async check: wait 500ms before API call
    yield* Effect.sleep("500 millis")

    // Async availability check
    const available = yield* checkUsernameAvailable(
      username as Username
    )

    return available
  })

// 6. Usage
const signupData = {
  username: "alice_123",
  email: "alice@example.com",
  password: "securepass123",
}

Effect.runPromise(validateSignUp(signupData))
  .then((result) => {
    console.log("✅ Sign up valid!")
    console.log(result)
  })
  .catch((error) => {
    console.error(`❌ Validation failed:`, error.message)
  })

// 7. Real-time checking with debounce
const checkUsernameField = (username: string) =>
  validateUsernameRealtime(username)
    .pipe(
      Effect.timeout("2 seconds"), // Timeout if API is slow
      Effect.match({
        onSuccess: (available) => ({
          valid: available,
          message: "Username available!",
        }),
        onFailure: (error) => ({
          valid: false,
          message: error.message,
        }),
      })
    )
    .pipe(Effect.runPromise)

// Triggered on user input after 500ms of inactivity
export const onUsernameChange = (value: string) => {
  checkUsernameField(value).then((result) => {
    console.log(result)
    // Update UI with result
  })
}

Why This Works

Concept Explanation
Sync first Fast validation catches obvious errors before API call
Async second Check database/API for uniqueness
Effect.all Parallel async checks (username AND email in parallel)
Effect.sleep Debounce rapid input (wait 500ms before API call)
Effect.timeout Prevent hanging if API is slow
Errors in channel No thrown exceptions, typed error handling

When to Use

  • Username/email availability checks
  • Real-time field validation with API calls
  • Cross-domain duplicate checks
  • Any validation requiring external data
  • UI feedback while user is typing

Related Patterns