| 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... |
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.
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
})
}
| 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 |
- 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