Patterns and techniques. Each recipe is self-contained.
Eliminate wrapper lambdas in pipelines with curried helpers. Exported from both result and option modules.
// Ugly: repetitive wrapper lambdas
const result = pipe(
ok(5),
(r) => map(r, (x) => x * 2),
(r) => map(r, (x) => x + 1),
(r) => flatMap(r, (x) => divide(x, 3)),
);// Clean: point-free composition
const result = pipe(
ok(5),
mapWith((x) => x * 2),
mapWith((x) => x + 1),
flatMapWith((x) => divide(x, 3)),
);import { mapWith, flatMapWith, mapErrWith, filterWith, tapWith, tapErrWith } from '@railway-ts/pipelines/result';import { mapWith, flatMapWith, filterWith, tapWith, mapToResultWith } from '@railway-ts/pipelines/option';import { flow } from '@railway-ts/pipelines/composition';
import { mapWith, flatMapWith, tapWith } from '@railway-ts/pipelines/result';
import { validate } from '@railway-ts/pipelines/schema';
const processData = flow(
(input: unknown) => validate(input, schema),
mapWith(transformData),
flatMapWith(validateBusinessRules),
tapWith((data) => console.log('Processing:', data)),
mapWith(enrichData),
);import { flatMapWith } from '@railway-ts/pipelines/result';
import { pipeAsync } from '@railway-ts/pipelines/composition';
const processOrder = async (input: unknown) => {
const result = await pipeAsync(
validate(input, orderSchema),
flatMapWith(validateInventory),
flatMapWith(chargePayment),
flatMapWith(createShipment),
);
return match(result, {
ok: (order) => ({ success: true, order }),
err: (error) => ({ success: false, error }),
});
};When an object() schema contains async validators, all fields are validated concurrently using Promise.all. This means three async field validators that each take 50ms finish in ~50ms total, not 150ms:
import { object, required, chainAsync, string, refineAsync, validate } from '@railway-ts/pipelines/schema';
const signupSchema = object({
email: required(
chainAsync(
string(),
refineAsync(async (email) => {
return !(await isEmailTaken(email)); // ~50ms
}, 'Email already in use'),
),
),
username: required(
chainAsync(
string(),
refineAsync(async (name) => {
return !(await isUsernameTaken(name)); // ~50ms
}, 'Username taken'),
),
),
invite: required(
chainAsync(
string(),
refineAsync(async (code) => {
return await isValidInvite(code); // ~50ms
}, 'Invalid invite code'),
),
),
});
// All three checks run in parallel: ~50ms total, not ~150ms
const result = await validate(input, signupSchema);This applies to object(), tuple(), and tupleOf(). If all validators in the schema are synchronous, the return type stays synchronous -- you don't pay for async when you don't use it.
pipeAsync awaits each step, so you can freely mix sync and async functions:
const process = async (input: unknown) =>
await pipeAsync(
validate(input, schema), // sync
mapWith(enrichData), // sync
flatMapWith(fetchDB), // async
mapWith(transform), // sync
flatMapWith(saveDB), // async
);tapWith accepts async functions. Side effects run without altering the Result:
import { tapWith, tapErrWith } from '@railway-ts/pipelines/result';
const processAndAudit = async (input: unknown) =>
await pipeAsync(
validate(input, schema),
flatMapWith(processPayment),
tapWith(async (payment) => {
await auditLog.write({ event: 'payment_processed', payment });
}),
tapErrWith(async (errors) => {
await auditLog.write({ event: 'payment_failed', errors });
}),
);Use flowAsync to define an async pipeline as a reusable function:
import { flowAsync } from '@railway-ts/pipelines/composition';
import { mapWith, flatMapWith, tapWith } from '@railway-ts/pipelines/result';
const processOrder = flowAsync(
(input: unknown) => validate(input, orderSchema),
flatMapWith(validateInventory),
flatMapWith(chargePayment),
tapWith(async (order) => {
await sendConfirmationEmail(order);
}),
flatMapWith(createShipment),
);
// Use it
const result = await processOrder(rawInput);import { flow } from '@railway-ts/pipelines/composition';
import { mapWith, flatMapWith, tapWith, tapErrWith } from '@railway-ts/pipelines/result';
const validateAndTransform = flow(
(input: unknown) => validate(input, inputSchema),
flatMapWith(validateBusinessRules),
flatMapWith(checkAgainstDatabase),
mapWith(transformForStorage),
);Use refineAt after object() to validate relationships between fields. Errors are attached to a specific path:
import { object, required, string, chain, refineAt } from '@railway-ts/pipelines/schema';
const signupSchema = chain(
object({
password: required(string()),
confirmPassword: required(string()),
}),
refineAt('confirmPassword', (data) => data.password === data.confirmPassword, 'Passwords must match'),
);
const result = validate({ password: 'abc', confirmPassword: 'xyz' }, signupSchema);
// Err([{ path: ['confirmPassword'], message: 'Passwords must match' }])formatErrors flattens ValidationError[] into Record<string, string> keyed by dot-path. ROOT_ERROR_KEY ("_root") is used for errors without a field path:
import { validate, formatErrors, ROOT_ERROR_KEY } from '@railway-ts/pipelines/schema';
import { match } from '@railway-ts/pipelines/result';
const result = validate(input, schema);
match(result, {
ok: (data) => submitForm(data),
err: (errors) => {
const formatted = formatErrors(errors);
// { name: 'Required', 'address.zip': 'Must be 5 digits', _root: 'Form invalid' }
// Wire to form state
for (const [field, message] of Object.entries(formatted)) {
if (field === ROOT_ERROR_KEY) {
showBanner(message);
} else {
setFieldError(field, message);
}
}
},
});By default, validation collects every error. Pass { abortEarly: true } to stop on the first failure — useful for large forms or performance-sensitive paths:
import {
validate,
validateAndFormatResult,
object,
required,
chain,
string,
minLength,
email,
} from '@railway-ts/pipelines/schema';
const userSchema = object({
name: required(chain(string(), minLength(3))),
email: required(chain(string(), email())),
});
const input = { name: '', email: 'bad' };
// Default: collects all errors
const all = validate(input, userSchema);
// Err([{ path: ['name'], ... }, { path: ['email'], ... }])
// Abort early: stops at first error
const first = validate(input, userSchema, { abortEarly: true });
// Err([{ path: ['name'], message: 'Must be at least 3 characters' }])
// Works with validateAndFormatResult too
const result = validateAndFormatResult(input, userSchema, { abortEarly: true });
// { valid: false, errors: { name: 'Must be at least 3 characters' } }abortEarly propagates through nested object(), array(), tuple(), and tupleOf() validators — the entire tree stops as soon as the first error is found.
Use is() when you only need a boolean — no error details, no Result unwrapping:
import { is, string, chain, minLength, object, required, email } from '@railway-ts/pipelines/schema';
// Primitives
is('hello', string()); // true
is(42, string()); // false
// Composed validators
const shortString = chain(string(), minLength(5));
is('hello', shortString); // true
is('hi', shortString); // false
// Objects
const userSchema = object({
name: required(chain(string(), minLength(2))),
email: required(chain(string(), email())),
});
is({ name: 'Alice', email: 'alice@example.com' }, userSchema); // true
is({ name: 'Alice', email: 'nope' }, userSchema); // falseis() uses abortEarly internally — it short-circuits on the first error since you only need pass/fail.
Use when to apply different validators based on runtime data. The value passes through unchanged when the predicate is false and no elseValidator is provided:
import { chain, object, required, string, stringEnum, when, refineAt } from '@railway-ts/pipelines/schema';
type ContactForm = {
contactMethod: 'email' | 'phone';
email: string;
phone: string;
};
const contactSchema = chain(
object({
contactMethod: required(stringEnum(['email', 'phone'] as const)),
email: required(string()),
phone: required(string()),
}),
when<ContactForm>(
(d) => d.contactMethod === 'email',
refineAt('email', (d) => d.email.includes('@'), 'Valid email required'),
refineAt('phone', (d) => d.phone.length >= 10, 'Phone must be at least 10 digits'),
),
);For more complex branching (e.g. completely different schemas per variant), use flatMap directly:
import { flatMap, ok } from '@railway-ts/pipelines/result';
const validateConditionally = (input: unknown) => {
const baseResult = validate(input, baseSchema);
return flatMap(baseResult, (data) => {
if (data.type === 'premium') {
return validate(data, premiumSchema);
}
return ok(data);
});
};const validateAndLog = flow(
(input: unknown) => validate(input, schema),
tapWith((data) => logger.info('Validated:', data)),
tapErrWith((errors) => logger.error('Failed:', errors)),
);Use when you need all results to succeed. Returns the first error encountered:
import { combine } from '@railway-ts/pipelines/result';
const fetchUserData = async (userId: string) => {
const [profile, settings, preferences] = await Promise.all([
fetchProfile(userId),
fetchSettings(userId),
fetchPreferences(userId),
]);
return combine([profile, settings, preferences]);
// Result<[Profile, Settings, Preferences], Error>
};
match(await fetchUserData('123'), {
ok: ([profile, settings, preferences]) => {
// All three succeeded, full type safety
return { profile, settings, preferences };
},
err: (error) => handleError(error),
});Use for form validation where you want to show every error at once:
import { combineAll } from '@railway-ts/pipelines/result';
const validateForm = (data: { name: unknown; email: unknown; age: unknown }) => {
const name = validate(data.name, nameSchema);
const email = validate(data.email, emailSchema);
const age = validate(data.age, ageSchema);
return combineAll([name, email, age]);
// Result<[string, string, number], ValidationError[][]>
};
match(validateForm(input), {
ok: ([name, email, age]) => createUser({ name, email, age }),
err: (errorGroups) => displayAllErrors(errorGroups.flat()),
});When you need both successes and failures from a batch operation:
import { ok, err, partition } from '@railway-ts/pipelines/result';
const results = users.map((user) => validateUser(user));
const { successes, failures } = partition(results);
console.log(`${successes.length} valid, ${failures.length} invalid`);
// Process valid users
saveUsers(successes);
// Report invalid ones
reportErrors(failures);combine |
combineAll |
partition |
|
|---|---|---|---|
| Stops at | First error | Collects all | Collects all |
| Returns | Result<T[], E> |
Result<T[], E[]> |
{ successes: T[]; failures: E[] } |
| Error type | E (single) |
E[] (array of all) |
E[] (array of all) |
| Use case | Parallel fetches, dependent operations | Form validation (all-or-nothing) | Batch processing (partial success) |
Recover from an Err by providing an alternative Result:
import { ok, err, orElse } from '@railway-ts/pipelines/result';
const primary = err('primary failed');
const recovered = orElse(primary, (error) => ok('fallback value'));
// Ok('fallback value')import { pipe } from '@railway-ts/pipelines/composition';
import { orElseWith, flatMapWith, mapWith } from '@railway-ts/pipelines/result';
const fetchWithFallback = (id: string) =>
pipe(
fetchFromCache(id),
orElseWith(() => fetchFromDatabase(id)),
orElseWith(() => fetchFromBackupService(id)),
mapWith(normalize),
);Apply different transformations to Ok and Err in one step:
import { bimap } from '@railway-ts/pipelines/result';
const result = bimap(
fetchUser(id),
(user) => user.displayName, // transform Ok
(error) => `Failed: ${error.code}`, // transform Err
);
// Result<string, string>import { discriminatedUnion, literal, object, required, number } from '@railway-ts/pipelines/schema';
const shapeSchema = discriminatedUnion('type', {
circle: object({
type: required(literal('circle')),
radius: required(number()),
}),
rectangle: object({
type: required(literal('rectangle')),
width: required(number()),
height: required(number()),
}),
});
const result = validate(input, shapeSchema);
match(result, {
ok: (shape) => {
// TypeScript knows shape.type is 'circle' | 'rectangle'
if (shape.type === 'circle') {
return Math.PI * shape.radius ** 2;
} else {
return shape.width * shape.height;
}
},
err: (errors) => 0,
});Three ways to validate enums, each for a different use case:
stringEnum -- String union types (most common):
import { stringEnum } from '@railway-ts/pipelines/schema';
const roleValidator = stringEnum(['admin', 'editor', 'viewer'] as const);
// Validator<unknown, 'admin' | 'editor' | 'viewer'>
validate('admin', roleValidator); // Ok('admin')
validate('hacker', roleValidator); // Err(...)enumValue -- TypeScript enum keyword:
import { enumValue } from '@railway-ts/pipelines/schema';
enum Status {
Active = 'active',
Inactive = 'inactive',
}
const statusValidator = enumValue(Status);
// Validator<unknown, Status>
validate('active', statusValidator); // Ok(Status.Active)oneOf -- Any fixed set of values:
import { oneOf } from '@railway-ts/pipelines/schema';
const priorityValidator = oneOf([1, 2, 3] as const);
// Validator<1 | 2 | 3>
validate(2, priorityValidator); // Ok(2)
validate(5, priorityValidator); // Err(...)stringEnum |
enumValue |
oneOf |
|
|---|---|---|---|
| Input | readonly string[] |
TS enum object |
readonly T[] |
| Output type | String union | Enum type | Union of values |
| Use when | String literals | TS enums | Numbers, mixed types |
Convert between Option, Result, and Promise when crossing module boundaries.
When an optional lookup needs to participate in a Result pipeline:
import { fromNullable, mapToResult } from '@railway-ts/pipelines/option';
const users = new Map([['1', 'Alice']]);
const userResult = mapToResult(fromNullable(users.get('999')), 'User not found');
// Result<string, string> -- Err('User not found')When you care about the value but not the specific error:
import { fromTry, mapToOption } from '@railway-ts/pipelines/result';
const parsed = mapToOption(fromTry(() => JSON.parse(text)));
// Option<any> -- Some if parsed, None if threwBridge into async code that expects thrown errors:
import { err, ok, toPromise } from '@railway-ts/pipelines/result';
const value = await toPromise(ok(42));
// 42
await toPromise(err('boom'));
// throws 'boom'import { pipe } from '@railway-ts/pipelines/composition';
import { fromNullable, mapToResult } from '@railway-ts/pipelines/option';
import { err, flatMapWith, match, ok } from '@railway-ts/pipelines/result';
const getApiKey = () => fromNullable(process.env.API_KEY);
const callApi = (key: string) => (key === 'valid' ? ok('response data') : err('Invalid API key'));
const result = pipe(mapToResult(getApiKey(), 'API key not configured'), flatMapWith(callApi));
match(result, {
ok: (data) => console.log(data),
err: (error) => console.error(error),
});import { chain, parseNumber, positive, precision, min, max, integer, between } from '@railway-ts/pipelines/schema';
// Currency: positive, 2 decimal places
const currency = chain(parseNumber(), positive(), precision(2));
// Age: integer between 0 and 150
const age = chain(parseNumber(), integer(), between(0, 150));
// Temperature: any number in range
const temperature = chain(parseNumber(), min(-273.15), max(1_000_000));import { chain, parseDate, parseISODate, todayOrFuture, pastDate, dateRange } from '@railway-ts/pipelines/schema';
// Booking date: must be in the future
const bookingDate = chain(parseDate(), todayOrFuture());
// Birth date: must be in the past
const birthDate = chain(parseDate(), pastDate());
// Event window: within a specific range
const eventDate = chain(parseISODate(), dateRange(new Date('2025-01-01'), new Date('2025-12-31')));import { chain, string, uuid, isoDate, isoTime, isoDateTime, isoDuration } from '@railway-ts/pipelines/schema';
// UUID
const id = chain(string(), uuid());
// ISO date (YYYY-MM-DD)
const dateStr = chain(string(), isoDate());
// ISO time with optional timezone
const timeStr = chain(string(), isoTime());
// Full ISO datetime
const timestamp = chain(string(), isoDateTime());
// ISO duration (e.g. P1Y2M3DT4H5M6S)
const duration = chain(string(), isoDuration());emptyAsOptional treats empty strings (and null/undefined) as undefined instead of failing validation. Useful for HTML forms where blank fields submit as "":
import { object, required, optional, string, emptyAsOptional, chain, parseNumber } from '@railway-ts/pipelines/schema';
const formSchema = object({
name: required(string()),
nickname: optional(emptyAsOptional(string())), // "" -> undefined
age: optional(emptyAsOptional(chain(parseNumber()))), // "" -> undefined, "25" -> 25
});
validate({ name: 'Alice', nickname: '', age: '' }, formSchema);
// Ok({ name: 'Alice' }) -- nickname and age are undefined, not errorsimport { chain, object, optional, pattern, required, string } from '@railway-ts/pipelines/schema';
const email = () => chain(string(), pattern(/^[^@]+@[^@]+\.[^@]+$/));
const phoneUS = () => chain(string(), pattern(/^\d{3}-\d{3}-\d{4}$/));
const zipCode = () => chain(string(), pattern(/^\d{5}$/));
const contactSchema = object({
email: required(email()),
phone: optional(phoneUS()),
zip: required(zipCode()),
});toStandardSchema wraps any validator as a Standard Schema v1 object, making it compatible with tRPC, TanStack Form, React Hook Form, and other tools that accept Standard Schema.
import { toStandardSchema, type StandardSchemaV1 } from '@railway-ts/pipelines/schema';
const userSchema = object({
name: required(string()),
age: required(chain(parseNumber(), min(18))),
});
const standardUser = toStandardSchema(userSchema);
// StandardSchemaV1<unknown, { name: string; age: number }>Works with both sync and async validators:
const asyncSchema = chainAsync(
object({ email: required(string()) }),
refineAtAsync(
'email',
async (data) => {
const exists = await checkEmailExists(data.email);
return !exists;
},
'Email already in use',
),
);
const standardAsync = toStandardSchema(asyncSchema);
// Can be passed to any Standard Schema consumerConvert multi-argument functions to chains of single-argument functions and back:
import { curry, uncurry } from '@railway-ts/pipelines/composition';
const add = (a: number, b: number) => a + b;
const curriedAdd = curry(add);
const add5 = curriedAdd(5);
add5(3); // 8
// Reverse it
const originalAdd = uncurry(curriedAdd);
originalAdd(5, 3); // 8Convert between multi-argument and tuple-accepting functions. Pairs naturally with combine:
import { tupled, untupled } from '@railway-ts/pipelines/composition';
import { combine, mapWith, match } from '@railway-ts/pipelines/result';
import { pipe } from '@railway-ts/pipelines/composition';
const createUser = (name: string, age: number) => ({ name, age });
const tupledCreate = tupled(createUser);
// ([string, number]) => { name: string; age: number }
// Combine validated fields, then pass the tuple directly
const result = pipe(combine([validateName(input.name), validateAge(input.age)]), mapWith(tupledCreate));
// Result<{ name: string; age: number }, ValidationError>
// Reverse it
const originalCreate = untupled(tupledCreate);
originalCreate('Alice', 30); // { name: 'Alice', age: 30 }import { isOk, isErr } from '@railway-ts/pipelines/result';
test('validates user input', () => {
const result = validateUser({ name: 'Alice', age: 25 });
expect(isOk(result)).toBe(true);
if (isOk(result)) {
expect(result.value.name).toBe('Alice');
}
});
test('rejects invalid age', () => {
const result = validateUser({ name: 'Bob', age: -5 });
expect(isErr(result)).toBe(true);
if (isErr(result)) {
expect(result.error).toContain('age');
}
});test('processes order end-to-end', async () => {
const result = await processOrder({ item: 'widget', quantity: 5 });
expect(isOk(result)).toBe(true);
if (isOk(result)) {
expect(result.value.status).toBe('shipped');
}
});
test('rejects invalid quantity', async () => {
const result = await processOrder({ item: 'widget', quantity: -1 });
expect(isErr(result)).toBe(true);
});Full pipeline: validate launch parameters, fetch weather data, make GO/NO-GO decision.
import { pipeAsync } from '@railway-ts/pipelines/composition';
import { err, flatMapWith, fromPromise, match, ok, type Result } from '@railway-ts/pipelines/result';
import {
formatErrors,
object,
required,
chain,
parseNumber,
min,
max,
stringEnum,
parseDate,
validate,
type ValidationError,
type ValidationResult,
type InferSchemaType,
} from '@railway-ts/pipelines/schema';
// Schema
const launchSchema = object({
vehicleType: required(stringEnum(['falcon9', 'atlas5'] as const)),
payload: required(chain(parseNumber(), min(1000), max(25_000))),
latitude: required(chain(parseNumber(), min(-90), max(90))),
longitude: required(chain(parseNumber(), min(-180), max(180))),
windowStart: required(parseDate()),
});
type LaunchParams = InferSchemaType<typeof launchSchema>;
type VehicleType = LaunchParams['vehicleType'];
// Weather API types
type WeatherData = {
wind_speed_10m: number;
wind_direction_10m: number;
wind_gusts_10m: number;
};
type LaunchContext = {
params: LaunchParams;
weather: WeatherData;
};
// Helper for API responses
const toJsonIfOk = (res: Response) => (res.ok ? res.json() : Promise.reject(`HTTP ${res.status}`));
// Fetch daily forecast for the launch window date
const fetchWeatherWithParams = async (params: LaunchParams): Promise<Result<LaunchContext, ValidationError[]>> => {
const date = params.windowStart.toISOString().split('T')[0]!;
const url = new URL('https://api.open-meteo.com/v1/forecast');
url.searchParams.append('latitude', params.latitude.toString());
url.searchParams.append('longitude', params.longitude.toString());
url.searchParams.append('daily', 'wind_speed_10m_max,wind_direction_10m_dominant,wind_gusts_10m_max');
url.searchParams.append('wind_speed_unit', 'ms');
url.searchParams.append('start_date', date);
url.searchParams.append('end_date', date);
const result = await fromPromise(fetch(url.toString()).then(toJsonIfOk));
return match(result, {
ok: (data) =>
ok({
params,
weather: {
wind_speed_10m: data.daily.wind_speed_10m_max[0],
wind_direction_10m: data.daily.wind_direction_10m_dominant[0],
wind_gusts_10m: data.daily.wind_gusts_10m_max[0],
},
}),
err: (msg) => err([{ path: ['weather_api'], message: String(msg) }]),
});
};
type LaunchDecision = {
windSpeed: number;
windGusts: number;
maxAllowed: number;
};
// Business rule check — can fail, so flatMapWith
const assessLaunchConditions = (context: LaunchContext): Result<LaunchDecision, ValidationError[]> => {
const windLimits: Record<VehicleType, number> = {
falcon9: 15,
atlas5: 12,
};
const maxWind = windLimits[context.params.vehicleType];
const actualMaxWind = Math.max(context.weather.wind_speed_10m, context.weather.wind_gusts_10m);
if (actualMaxWind > maxWind) {
return err([{ path: ['weather'], message: `Wind ${actualMaxWind} m/s exceeds ${maxWind} m/s limit` }]);
}
return ok({
windSpeed: context.weather.wind_speed_10m,
windGusts: context.weather.wind_gusts_10m,
maxAllowed: maxWind,
});
};
// Main pipeline
const evaluateLaunch = async (input: unknown): Promise<ValidationResult<LaunchDecision>> => {
const validationResult = validate(input, launchSchema);
const result = await pipeAsync(
validationResult,
flatMapWith(fetchWeatherWithParams),
flatMapWith(assessLaunchConditions),
);
return match(result, {
ok: (decision) => ({ valid: true as const, data: decision }),
err: (errors) => ({ valid: false as const, errors: formatErrors(errors) }),
});
};
// Usage
const result = await evaluateLaunch({
vehicleType: 'falcon9',
payload: 1000,
latitude: 28.5721,
longitude: -80.648,
windowStart: new Date(),
});
console.log(result);The pattern:
- Validate at boundary with schema
- Chain async operations with
pipeAsync+flatMapWith - Pure business logic as sync functions that return
Result(can fail viaErr) - Branch once at the end with
match
- API Reference — Every function signature and description