@ngwes/mini-fp
v1.4.0
Published
A minimal TypeScript functional programming library inspired by [language-ext](https://github.com/louthy/language-ext) for .NET. Provides monadic types to handle absence, errors, and async flows without null checks or exceptions.
Downloads
1,224
Readme
mini-fp
A minimal TypeScript functional programming library inspired by language-ext for .NET. Provides monadic types to handle absence, errors, and async flows without null checks or exceptions.
Zero production dependencies.
Table of Contents
- Installation
- Quick Start
- Option<T>
- Either<L, R>
- Try<T>
- TryAsync<T>
- Validate<L, R>
- Unit
- Traversal Utilities
- Real-World Recipes
- API Reference
Installation
npm i @ngwes/mini-fpQuick Start
import { Option, Either, Try, Validate, traverseOption } from 'mini-fp';
const { some, none, from } = Option;
const { left, right } = Either;
const { run } = Try;
const { valid, invalid } = Validate;Each type is a plain class with static constructors. You can call static methods directly (Option.some(42)) or destructure them for a terser style (const { some } = Option).
Option<T>
Option<T> represents a value that may or may not be present. It is either some(value) (a value exists) or none() (nothing). Use it instead of returning null or undefined.
Constructors
import { Option } from 'mini-fp';
const { some, none, from } = Option;
// some: wraps a non-null value (throws if null/undefined is passed)
const a = some(42); // Option<number>
// none: represents the absence of a value
const b = none<number>(); // Option<number>
// from: safe constructor — converts null/undefined to none, anything else to some
const c = from(null); // none
const d = from(undefined); // none
const e = from("hello"); // some("hello")
// from with noneWhen: treats a value as none when the predicate returns true
const h = from(-1, n => n < 0); // none
const i = from(5, n => n < 0); // some(5)
// FromAsync: awaits a Promise, then applies from
const f = await Option.FromAsync(fetchUserOrNull(id)); // Promise<Option<User>>Checking the state
some(1).isSome(); // true
some(1).isNone(); // false
none().isSome(); // false
none().isNone(); // trueTransforming: map
map applies a function to the inner value if present, leaving none untouched.
some(5).map(x => x * 2); // some(10)
none<number>().map(x => x * 2); // none
// Chaining
some(" hello ")
.map(s => s.trim())
.map(s => s.toUpperCase()); // some("HELLO")Chaining: bind
bind (also called flatMap) is for operations that themselves return an Option. It prevents nesting (Option<Option<T>>).
const parsePositive = (n: number): Option<number> =>
n > 0 ? some(n) : none();
some(5).bind(parsePositive); // some(5)
some(-1).bind(parsePositive); // none
none<number>().bind(parsePositive); // none
// Real example: safe dictionary lookup
const users = new Map([["alice", { age: 30 }]]);
const lookup = (name: string): Option<{ age: number }> => from(users.get(name));
from("alice")
.bind(lookup)
.map(u => u.age); // some(30)
from("bob")
.bind(lookup)
.map(u => u.age); // nonePattern matching: match
match forces you to handle both cases explicitly, eliminating the need for null checks.
const greeting = some("Alice").match(
name => `Hello, ${name}!`,
() => "Hello, stranger!"
);
// "Hello, Alice!"
const fallback = none<string>().match(
name => `Hello, ${name}!`,
() => "Hello, stranger!"
);
// "Hello, stranger!"Fallback values: orElse
// With a plain value
some(1).orElse(99); // 1
none<number>().orElse(99); // 99
// With a factory function (lazy — only called when none)
none<string>().orElse(() => computeExpensiveDefault()); // result of factory
// Common pattern: provide a default user
const user = from(sessionUser).orElse(guestUser);Unwrapping
Only unwrap when you have already confirmed the option is some:
const opt = from(maybeValue);
if (opt.isSome()) {
console.log(opt.unwrap()); // safe
}
// Unsafe — throws if none:
some(42).unwrap(); // 42
none().unwrap(); // throws ErrorAsync API
Every operation has an Async counterpart:
import { Option } from 'mini-fp';
// FromAsync: wraps a nullable Promise
const user = await Option.FromAsync(db.findUser(id)); // Promise<Option<User>>
// mapAsync
const upper = await from("hello").mapAsync(async s => s.toUpperCase());
// some("HELLO")
// bindAsync — returns OptionAsync<U>, which is chainable and awaitable
const profile = await from(userId).bindAsync(async id => {
const row = await db.query("SELECT * FROM profiles WHERE id = ?", [id]);
return from(row ?? null); // none if not found
});
// Chain multiple async steps directly — no .then() unwrapping needed
const result = await from(sessionToken)
.bindAsync(async token => from(await auth.validateToken(token)))
.bindAsync(async id => from(await db.users.findById(id)));
// Also accepts a Promise<Option<U>> directly
const fromPromise = await from(value).bindAsync(fetchOptionFromSomewhere());
// matchAsync
const response = await from(user).matchAsync(
async u => ({ status: 200, body: u }),
async () => ({ status: 404, body: "Not found" })
);
// orElseAsync
const config = await from(process.env.API_URL).orElseAsync(async () => {
const val = await remoteConfigService.get("API_URL");
return val;
});Worked example: safe navigation through nested objects
type Address = { city: string; zip: string };
type User = { name: string; address: Address | null };
const getCity = (userId: string): Option<string> => {
const user: User | null = userRepository.findById(userId);
return from(user)
.bind(u => from(u.address))
.map(a => a.city);
};
getCity("u1").orElse("Unknown city");Either<L, R>
Either<L, R> represents a computation that can succeed with a right value or fail with a left value. By convention, Left carries the error and Right carries the success. Use it when you need to propagate structured error information alongside success values.
Constructors
import { Either } from 'mini-fp';
const { left, right } = Either;
const ok = right<string, number>(42); // Either<string, number>
const err = left<string, number>("not found"); // Either<string, number>Checking the state
right(1).isRight(); // true
right(1).isLeft(); // false
left("e").isLeft(); // trueTransforming: map and mapLeft
// map transforms the right (success) side
right<string, number>(10).map(n => n * 2); // right(20)
left<string, number>("err").map(n => n * 2); // left("err") — untouched
// mapLeft transforms the left (error) side
left<string, number>("not_found").mapLeft(code => ({ code, label: "Not Found" }));
// left({ code: "not_found", label: "Not Found" })Chaining: bind and bindLeft
const parseAge = (s: string): Either<string, number> => {
const n = parseInt(s, 10);
return isNaN(n) ? left("invalid number") : right(n);
};
const validateAge = (n: number): Either<string, number> =>
n >= 0 && n <= 150 ? right(n) : left("age out of range");
// Pipeline — each step only runs if the previous succeeded
right<string, string>("25")
.bind(parseAge)
.bind(validateAge); // right(25)
right<string, string>("abc")
.bind(parseAge)
.bind(validateAge); // left("invalid number")
right<string, string>("200")
.bind(parseAge)
.bind(validateAge); // left("age out of range")Pattern matching: match
const message = right<string, number>(42).match(
value => `Success: ${value}`,
error => `Error: ${error}`
);
// "Success: 42"
const httpStatus = left<string, number>("unauthorized").match(
_ => 200,
code => code === "unauthorized" ? 401 : 400
);
// 401Unwrapping
right(5).unwrapRight(); // 5
left("oops").unwrapLeft(); // "oops"
right(5).unwrapLeft(); // throws
left("oops").unwrapRight(); // throwsAsync API
// mapAsync / mapLeftAsync
const result = await right<string, string>("hello")
.mapAsync(async s => s.toUpperCase()); // right("HELLO")
// bindAsync — returns EitherAsync<L, U>, which is chainable and awaitable
const user = await right<ApiError, string>("user-123")
.bindAsync(async id => {
const row = await db.users.findById(id);
return row ? right(row) : left({ code: "NOT_FOUND", message: "User not found" });
});
// Chain multiple async steps directly — no .then() unwrapping needed
const result = await right<ApiError, string>("user-123")
.bindAsync(async id => fetchUser(id))
.bindAsync(async user => fetchPermissions(user));
// bindAsync also accepts a function returning EitherAsync directly
const result2 = await right<ApiError, string>("user-123")
.bindAsync(id => EitherAsync.from(fetchUser(id)));
// bindLeftAsync — chains on the left (error) side
const recovered = await left<string, number>("not_found")
.bindLeftAsync(async code => fallbackLookup(code));
// bindLeftAsync also accepts a function returning EitherAsync directly
const recovered2 = await left<string, number>("not_found")
.bindLeftAsync(code => EitherAsync.from(fallbackLookup(code)));
// Also accepts a Promise<Either<L, U>> directly
const fromPromise = await right<string, string>(id).bindAsync(fetchEitherFromSomewhere());
// EitherAsync.right / EitherAsync.left — create an already-resolved EitherAsync directly
const asyncRight = EitherAsync.right<string, number>(42); // EitherAsync<string, number>
const asyncLeft = EitherAsync.left<string, number>("err"); // EitherAsync<string, number>
// matchAsync
const response = await result.matchAsync(
async value => ({ status: 200, data: value }),
async error => ({ status: 500, data: error })
);Worked example: HTTP request handler pipeline
type ApiError = { code: string; message: string };
type User = { id: string; email: string; role: string };
const authenticate = (token: string): Either<ApiError, string> =>
token === "valid"
? right("user-123")
: left({ code: "UNAUTHORIZED", message: "Invalid token" });
const fetchUser = async (id: string): Promise<Either<ApiError, User>> => {
const user = await db.users.findById(id);
return user
? right(user)
: left({ code: "NOT_FOUND", message: "User not found" });
};
const requireAdmin = (user: User): Either<ApiError, User> =>
user.role === "admin"
? right(user)
: left({ code: "FORBIDDEN", message: "Admin access required" });
// Compose the pipeline
const handleRequest = async (token: string): Promise<Either<ApiError, User>> =>
authenticate(token)
.bindAsync(id => fetchUser(id))
.bindAsync(user => Promise.resolve(requireAdmin(user)));
const result = await handleRequest("valid");
result.match(
admin => console.log(`Welcome, admin ${admin.email}`),
err => console.error(`${err.code}: ${err.message}`)
);Try<T>
Try<T> wraps a computation that might throw an exception. It captures the exception instead of letting it propagate, giving you a controlled way to handle failures — especially when calling third-party code you don't control.
Constructors
import { Try } from 'mini-fp';
const { run, runAsync } = Try;
// run: executes a synchronous function, catches any throw
const result = Try.run(() => JSON.parse(rawInput));
// runAsync: executes an async function, catches any throw or rejection
// returns TryAsync<T> for fluent error handling
const asyncResult = await Try.runAsync(() => fetch(url).then(r => r.json()));Checking the state
Try.run(() => 42).isSuccess(); // true
Try.run(() => { throw new Error() }).isFailure(); // trueHandling failures: onFail and onFailAsync
onFail is the synchronous recovery method. onFailAsync is its async counterpart and accepts any PromiseLike<T> — including EitherAsync<L, T> when T is Either<L, R>. Both return the success value directly, or call your handler with the error:
// Returns 42 on success
Try.run(() => 42).onFailAsync(_err => 0); // 42
// Calls the handler on failure
Try.run(() => { throw new Error("oops") })
.onFailAsync(err => {
console.error(err);
return -1;
});
// -1
// Async recovery — use TryAsync.runAsync for a fluent API
const data = await TryAsync.runAsync(() => fetchRemote())
.onFailAsync(async err => {
await logError(err);
return fallbackData;
});Worked example: safe JSON parsing
type Config = { apiUrl: string; timeout: number };
const parseConfig = (raw: string): Config => {
return Try.run<Config>(() => JSON.parse(raw))
.onFailAsync(_err => ({ apiUrl: "http://localhost", timeout: 5000 }));
};
parseConfig('{"apiUrl":"https://api.example.com","timeout":3000}');
// { apiUrl: "https://api.example.com", timeout: 3000 }
parseConfig("not valid json {{");
// { apiUrl: "http://localhost", timeout: 5000 } ← defaultWorked example: wrapping unreliable third-party code
import { Try } from 'mini-fp';
const readConfigFile = async (path: string): Promise<string> => {
const result = await Try.runAsync(() => fs.promises.readFile(path, "utf-8"));
return result.onFailAsync(err => {
console.warn(`Could not read ${path}:`, err);
return "{}";
});
};Worked example: converting exceptions to Either
import { Try, Either } from 'mini-fp';
const { left, right } = Either;
const tryToEither = <T>(fn: () => T): Either<Error, T> => {
const t = Try.run(fn);
return t.isSuccess()
? right(t.onFailAsync(() => null as never))
: left(t.onFailAsync(e => e as Error) as unknown as Error);
};TryAsync<T>
TryAsync<T> is the async counterpart of Try<T>. It wraps an async computation that might throw or return a rejected promise, and provides a fluent .onFailAsync() method so you can handle the error in a single expression — no await + separate then needed.
Constructor
import { TryAsync } from 'mini-fp';
const result = TryAsync.runAsync(() => fetch(url).then(r => r.json()));Try.runAsync also returns a TryAsync<T>, so both entry points are equivalent.
Handling failures: onFailAsync
.onFailAsync(handler) returns the success value directly, or calls your async handler with the error:
const data = await TryAsync.runAsync(() => fetchRemote())
.onFailAsync(async err => {
await logError(err);
return fallbackData;
});Awaiting the underlying Try<T>
TryAsync<T> implements PromiseLike<Try<T>>, so you can await it to get back a Try<T> and inspect or transform it manually:
const t = await TryAsync.runAsync(() => fetchRemote());
t.isSuccess(); // true / falseWorked example: fetching with fallback
import { TryAsync } from 'mini-fp';
const fetchConfig = (url: string): Promise<Config> =>
TryAsync.runAsync(() => fetch(url).then(r => r.json()))
.onFailAsync(_err => defaultConfig);Validate<L, R>
Validate<L, R> is designed for validation scenarios where you want to collect all errors rather than short-circuiting on the first one. Unlike Either, combining two invalid Validate instances accumulates their errors.
valid(value)— the value passed all validationsinvalid(errors)— one or more validation rules failed (errors is an array)
Constructors
import { Validate } from 'mini-fp';
const { valid, invalid } = Validate;
const ok = valid<string, number>(42); // Validate<string, number>
const err = invalid<string, number>(["must be positive"]); // Validate<string, number>Transforming and chaining
// map — transform the valid value
valid<string, number>(5).map(x => x * 2).unwrap(); // 10
invalid<string, number>(["err"]).map(x => x * 2); // still invalid(["err"])
// bind — chain a validation that itself returns a Validate
valid<string, number>(10)
.bind(n => n > 0 ? valid(n) : invalid(["must be positive"]));
// valid(10)Combining: combine and combineAsync
This is the key feature of Validate. When both sides are invalid, all errors are merged:
// Both valid → keeps first value
valid<string, number>(1).combine(valid(2)).unwrap(); // 1
// One invalid → collects all errors
valid<string, number>(10).combine(invalid(["too large"]));
// invalid(["too large"])
// Both invalid → accumulates errors
invalid<string, number>(["required"])
.combine(invalid(["must be a number", "must be positive"]));
// invalid(["required", "must be a number", "must be positive"])
// Async combine
const r = await valid<string, number>(5)
.combineAsync(Promise.resolve(invalid<string, number>(["async error"])));
// invalid(["async error"])Unwrapping
valid(42).unwrap(); // 42
valid(42).unwrapInvalid(); // throws
invalid(["oops"]).unwrapInvalid(); // ["oops"]
invalid(["oops"]).unwrap(); // throwsWorked example: form field validation
import { Validate } from 'mini-fp';
const { valid, invalid } = Validate;
type FieldError = { field: string; message: string };
const validateName = (name: string): Validate<FieldError, string> =>
name.trim().length > 0
? valid(name.trim())
: invalid([{ field: "name", message: "Name is required" }]);
const validateEmail = (email: string): Validate<FieldError, string> => {
if (!email.includes("@"))
return invalid([{ field: "email", message: "Invalid email address" }]);
if (email.length > 254)
return invalid([{ field: "email", message: "Email too long" }]);
return valid(email);
};
const validateAge = (age: number): Validate<FieldError, number> => {
const errors: FieldError[] = [];
if (age < 0) errors.push({ field: "age", message: "Age cannot be negative" });
if (age > 150) errors.push({ field: "age", message: "Age is unrealistic" });
return errors.length > 0 ? invalid(errors) : valid(age);
};
// Run all validations and collect every error
const result = validateName("")
.combine(validateEmail("not-an-email"))
.combine(validateAge(-5));
if (result.isInvalid()) {
result.unwrapInvalid().forEach(e =>
console.error(`[${e.field}] ${e.message}`)
);
}
// [name] Name is required
// [email] Invalid email address
// [age] Age cannot be negativeWorked example: validating a DTO before persistence
import { Validate } from 'mini-fp';
const { valid, invalid } = Validate;
interface CreateUserDto { username: string; password: string; email: string }
type ValidationError = string;
const validateUsername = (s: string): Validate<ValidationError, string> => {
if (s.length < 3) return invalid(["username: min 3 characters"]);
if (s.length > 20) return invalid(["username: max 20 characters"]);
if (!/^[a-z0-9_]+$/.test(s)) return invalid(["username: only lowercase letters, digits and underscores"]);
return valid(s);
};
const validatePassword = (s: string): Validate<ValidationError, string> => {
const errors: string[] = [];
if (s.length < 8) errors.push("password: min 8 characters");
if (!/[A-Z]/.test(s)) errors.push("password: needs an uppercase letter");
if (!/[0-9]/.test(s)) errors.push("password: needs a digit");
return errors.length ? invalid(errors) : valid(s);
};
const validateDto = (dto: CreateUserDto): Validate<ValidationError, CreateUserDto> =>
validateUsername(dto.username)
.combine(validatePassword(dto.password))
.map(() => dto); // if all valid, return the original DTO
const result = validateDto({
username: "ab",
password: "weakpass",
email: "[email protected]"
});
result.match(
dto => saveUser(dto),
errors => res.status(400).json({ errors })
);Unit
Unit represents the absence of a meaningful return value — the functional equivalent of void. It avoids polluting your types with void in generic contexts.
import { Unit } from 'mini-fp';
// Use when an operation succeeds but has nothing to return
const performSideEffect = (): Either<string, Unit> => {
try {
doSomething();
return Either.right(Unit.default);
} catch {
return Either.left("operation failed");
}
};
performSideEffect().match(
_unit => console.log("done"),
err => console.error(err)
);Traversal Utilities
Traversal functions flip a collection of monadic values into a single monad containing a collection. All traversals are fail-fast (returning none/left) except sequenceValidate, which accumulates.
traverseOption
Converts Option<T>[] → Option<T[]>. Returns none as soon as any element is none.
import { Option, traverseOption } from 'mini-fp';
const { some, none, from } = Option;
traverseOption([some(1), some(2), some(3)]); // some([1, 2, 3])
traverseOption([some(1), none(), some(3)]); // none ← short-circuits
traverseOption([]); // some([])
// Practical use: look up several keys that must all exist
const getRequiredEnvVars = (keys: string[]): Option<string[]> =>
traverseOption(keys.map(k => from(process.env[k])));
getRequiredEnvVars(["DB_HOST", "DB_PORT", "API_KEY"]);
// none if any variable is missing, some([...values]) if all presenttraverseOptionAsync
Converts Option<Promise<T>>[] → Promise<Option<T[]>>. Awaits all inner promises.
import { Option, traverseOptionAsync } from 'mini-fp';
const { some, none } = Option;
const results = await traverseOptionAsync([
some(Promise.resolve(1)),
some(Promise.resolve(2)),
]);
// some([1, 2])
const withMissing = await traverseOptionAsync([
some(Promise.resolve(1)),
none<Promise<number>>(),
]);
// nonetraverseEither
Converts Either<L, R>[] → Either<L, R[]>. Returns the first left encountered.
import { Either, traverseEither } from 'mini-fp';
const { left, right } = Either;
traverseEither([right(1), right(2), right(3)]); // right([1, 2, 3])
traverseEither([right(1), left("oops"), right(3)]); // left("oops")
// Practical use: parse a batch of inputs
const parseAll = (inputs: string[]): Either<string, number[]> =>
traverseEither(
inputs.map(s => {
const n = Number(s);
return isNaN(n) ? Either.left(`"${s}" is not a number`) : Either.right(n);
})
);
parseAll(["1", "2", "3"]); // right([1, 2, 3])
parseAll(["1", "abc", "3"]); // left('"abc" is not a number')traverseEitherAsync
Converts Either<L, Promise<R>>[] → Promise<Either<L, R[]>>.
import { Either, traverseEitherAsync } from 'mini-fp';
const { left, right } = Either;
const results = await traverseEitherAsync([
right(Promise.resolve(10)),
right(Promise.resolve(20)),
]);
// right([10, 20])
const withError = await traverseEitherAsync([
right(Promise.resolve(10)),
left("fetch failed"),
]);
// left("fetch failed")sequenceValidate
Converts Validate<L, R>[] → Validate<L, R[]>. Unlike traverseEither, it does not short-circuit — all validations run and all errors are collected.
import { Validate, sequenceValidate } from 'mini-fp';
const { valid, invalid } = Validate;
// All valid
sequenceValidate([valid(1), valid(2), valid(3)]).unwrap();
// [1, 2, 3]
// Collects all errors — not just the first
sequenceValidate([
invalid(["e1"]),
valid(5),
invalid(["e2", "e3"]),
]).unwrapInvalid();
// ["e1", "e2", "e3"]
// Practical: validate every item in an array
const validatePositive = (n: number): Validate<string, number> =>
n > 0 ? valid(n) : invalid([`${n} is not positive`]);
sequenceValidate([-1, 2, -3, 4].map(validatePositive));
// invalid(["-1 is not positive", "-3 is not positive"])sequenceValidateAsync
import { sequenceValidateAsync, Validate } from 'mini-fp';
const { valid, invalid } = Validate;
const result = await sequenceValidateAsync(
Promise.resolve([
valid<string, number>(1),
invalid<string, number>(["async error"]),
valid<string, number>(3),
])
);
// invalid(["async error"])Real-World Recipes
Recipe 1: Config loading with fallbacks
Load config from environment, fall back to file, fall back to defaults.
import { Option } from 'mini-fp';
const { from } = Option;
interface AppConfig { dbUrl: string; port: number; logLevel: string }
const fromEnv = (): Option<AppConfig> => {
const dbUrl = process.env.DATABASE_URL;
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : null;
const logLevel = process.env.LOG_LEVEL;
return from(dbUrl)
.bind(db => from(port).map(p => ({ dbUrl: db, port: p })))
.bind(partial => from(logLevel).map(l => ({ ...partial, logLevel: l })));
};
const fromFile = async (): Promise<Option<AppConfig>> => {
const text = await Option.FromAsync(
import("fs/promises").then(fs =>
fs.readFile("config.json", "utf-8").catch(() => null)
)
);
return text.bind(t => {
try { return Option.from<AppConfig>(JSON.parse(t)); }
catch { return Option.none(); }
});
};
const defaults: AppConfig = { dbUrl: "postgres://localhost/dev", port: 3000, logLevel: "info" };
const loadConfig = async (): Promise<AppConfig> => {
const envConfig = fromEnv();
if (envConfig.isSome()) return envConfig.unwrap();
const fileConfig = await fromFile();
return fileConfig.orElse(defaults);
};Recipe 2: User registration pipeline
Validate input, check for duplicates, hash the password, persist — each step can fail with a typed error.
import { Either, Try } from 'mini-fp';
const { left, right } = Either;
type RegistrationError =
| { type: "VALIDATION"; field: string; message: string }
| { type: "CONFLICT"; message: string }
| { type: "PERSISTENCE"; message: string };
interface RegisterDto { email: string; password: string }
interface User { id: string; email: string; passwordHash: string }
const validateEmail = (email: string): Either<RegistrationError, string> =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
? right(email)
: left({ type: "VALIDATION", field: "email", message: "Invalid email format" });
const validatePassword = (password: string): Either<RegistrationError, string> =>
password.length >= 8
? right(password)
: left({ type: "VALIDATION", field: "password", message: "Min 8 characters" });
const checkNotTaken = async (email: string): Promise<Either<RegistrationError, string>> => {
const existing = await db.users.findByEmail(email);
return existing
? left({ type: "CONFLICT", message: `${email} is already registered` })
: right(email);
};
const hashPassword = (password: string): Either<RegistrationError, string> => {
const t = Try.run(() => bcrypt.hashSync(password, 10));
return t.isSuccess()
? right(t.onFailAsync(() => ""))
: left({ type: "PERSISTENCE", message: "Failed to hash password" });
};
const register = async (dto: RegisterDto): Promise<Either<RegistrationError, User>> =>
validateEmail(dto.email)
.bind(() => validatePassword(dto.password))
.bindAsync(() => checkNotTaken(dto.email))
.bindAsync(_email => Promise.resolve(hashPassword(dto.password)))
.bindAsync(async hash => {
const user = await db.users.create({ email: dto.email, passwordHash: hash });
return right<RegistrationError, User>(user);
});
// Usage
const result = await register({ email: "[email protected]", password: "Secure1234" });
result.match(
user => res.status(201).json({ id: user.id }),
error => {
const status = error.type === "CONFLICT" ? 409
: error.type === "VALIDATION" ? 422
: 500;
res.status(status).json(error);
}
);Recipe 3: Batch processing with full error collection
Process a list of items and collect all failures, without stopping on the first one.
import { Validate, sequenceValidate } from 'mini-fp';
const { valid, invalid } = Validate;
interface ProductRow { sku: string; price: string; stock: string }
interface Product { sku: string; price: number; stock: number }
const parseProduct = (row: ProductRow, index: number): Validate<string, Product> => {
const price = parseFloat(row.price);
const stock = parseInt(row.stock, 10);
const priceV: Validate<string, number> =
isNaN(price) || price < 0
? invalid([`Row ${index}: invalid price "${row.price}"`])
: valid(price);
const stockV: Validate<string, number> =
isNaN(stock) || stock < 0
? invalid([`Row ${index}: invalid stock "${row.stock}"`])
: valid(stock);
return priceV
.combine(stockV)
.map(() => ({ sku: row.sku, price, stock }));
};
const importProducts = (rows: ProductRow[]): Validate<string, Product[]> =>
sequenceValidate(rows.map((row, i) => parseProduct(row, i)));
const csvRows: ProductRow[] = [
{ sku: "A1", price: "9.99", stock: "100" },
{ sku: "A2", price: "-1", stock: "50" }, // bad price
{ sku: "A3", price: "4.99", stock: "abc" }, // bad stock
];
const outcome = importProducts(csvRows);
if (outcome.isInvalid()) {
console.error("Import failed:", outcome.unwrapInvalid());
// ["Row 1: invalid price \"-1\"", "Row 2: invalid stock \"abc\""]
} else {
await productService.bulkInsert(outcome.unwrap());
}Recipe 4: Optional chaining across async boundaries
import { Option } from 'mini-fp';
const getRecommendations = async (sessionToken: string | null): Promise<string[]> => {
return Option.from(sessionToken)
.bindAsync(async token => {
const userId = await auth.validateToken(token);
return Option.from(userId);
})
.bindAsync(async id => {
const prefs = await db.preferences.findByUser(id);
return Option.from(prefs);
})
.then(opt => opt.mapAsync(async prefs => recommendations.forPreferences(prefs)))
.then(opt => opt.orElseAsync(async () => recommendations.trending()));
};API Reference
Option<T>
| | Signature | Description |
|---|---|---|
| Option.some | some<T>(value: T): Option<T> | Creates some; throws if value is null/undefined |
| Option.none | none<T>(): Option<T> | Creates none |
| Option.from | from<T>(value: T \| null \| undefined): Option<T>from<T>(value: T, noneWhen: (v: T) => boolean): Option<T> | Safe constructor from nullable; or use noneWhen to treat a value as absent based on a predicate |
| Option.FromAsync | FromAsync<T>(value: Promise<T> \| null \| undefined): Promise<Option<T>> | Safe async constructor |
| .isSome() | (): boolean | true if value is present |
| .isNone() | (): boolean | true if no value |
| .unwrap() | (): T | Returns value; throws if none |
| .map() | <U>(fn: (v: T) => U): Option<U> | Transforms the inner value |
| .mapAsync() | <U>(fn: (v: T) => Promise<U>): Promise<Option<U>> | Async map |
| .bind() | <U>(fn: (v: T) => Option<U>): Option<U> | Chains Option-returning functions |
| .bindAsync() | <U>(fn: (v: T) => Promise<Option<U>>): OptionAsync<U><U>(promise: Promise<Option<U>>): OptionAsync<U> | Async bind; returns chainable OptionAsync |
| .match() | <U>(onSome, onNone): U | Pattern match on some/none |
| .matchAsync() | <U>(onSome, onNone): Promise<U> | Async pattern match |
| .orElse() | (defaultValue: T \| (() => T)): T | Returns value or fallback |
| .orElseAsync() | (defaultValue: Promise<T> \| (() => Promise<T>)): Promise<T> | Async fallback |
Either<L, R>
| | Signature | Description |
|---|---|---|
| Either.left | left<L, R>(value: L): Either<L, R> | Creates left; throws if null/undefined |
| Either.right | right<L, R>(value: R): Either<L, R> | Creates right; throws if null/undefined |
| .isLeft() | (): boolean | true if left |
| .isRight() | (): boolean | true if right |
| .unwrapLeft() | (): L | Returns left value; throws if right |
| .unwrapRight() | (): R | Returns right value; throws if left |
| .map() | <U>(fn: (v: R) => U): Either<L, U> | Transforms the right value |
| .mapLeft() | <U>(fn: (v: L) => U): Either<U, R> | Transforms the left value |
| .bind() | <U>(fn: (v: R) => Either<L, U>): Either<L, U> | Chains on the right side |
| .bindLeft() | <U>(fn: (v: L) => Either<U, R>): Either<U, R> | Chains on the left side |
| .match() | <U>(onRight, onLeft): U | Pattern match on right/left |
| .mapAsync() | <U>(fn: (v: R) => Promise<U>): Promise<Either<L, U>> | Async map right |
| .mapLeftAsync() | <U>(fn: (v: L) => Promise<U>): Promise<Either<U, R>> | Async map left |
| .bindAsync() | <U>(fn: (v: R) => Promise<Either<L, U>>): EitherAsync<L, U><U>(fn: (v: R) => EitherAsync<L, U>): EitherAsync<L, U><U>(promise: Promise<Either<L, U>>): EitherAsync<L, U> | Async bind right; returns chainable EitherAsync |
| .bindLeftAsync() | <U>(fn: (v: L) => Promise<Either<U, R>>): EitherAsync<U, R><U>(fn: (v: L) => EitherAsync<U, R>): EitherAsync<U, R><U>(promise: Promise<Either<U, R>>): EitherAsync<U, R> | Async bind left; returns chainable EitherAsync |
| .matchAsync() | <U>(onRight, onLeft): Promise<U> | Async pattern match |
OptionAsync<T>
Returned by Option.bindAsync(). Wraps a Promise<Option<T>> and implements PromiseLike, so it can be awaited directly or chained further.
| | Signature | Description |
|---|---|---|
| OptionAsync.from | from<T>(promise: Promise<Option<T>>): OptionAsync<T> | Wraps an existing promise |
| .bindAsync() | <U>(fn: (v: T) => Promise<Option<U>>): OptionAsync<U><U>(promise: Promise<Option<U>>): OptionAsync<U> | Chains on some; short-circuits on none |
| .then() | PromiseLike.then | Allows await and .then() interop |
EitherAsync<L, R>
Returned by Either.bindAsync() and Either.bindLeftAsync(). Wraps a Promise<Either<L, R>> and implements PromiseLike, so it can be awaited directly or chained further.
| | Signature | Description |
|---|---|---|
| EitherAsync.from | from<L, R>(promise: Promise<Either<L, R>>): EitherAsync<L, R> | Wraps an existing promise |
| EitherAsync.right | right<L, R>(value: R): EitherAsync<L, R> | Creates an already-resolved right |
| EitherAsync.left | left<L, R>(value: L): EitherAsync<L, R> | Creates an already-resolved left |
| .bindAsync() | <U>(fn: (v: R) => Promise<Either<L, U>>): EitherAsync<L, U><U>(fn: (v: R) => EitherAsync<L, U>): EitherAsync<L, U><U>(promise: Promise<Either<L, U>>): EitherAsync<L, U> | Chains on right; short-circuits on left |
| .bindLeftAsync() | <U>(fn: (v: L) => Promise<Either<U, R>>): EitherAsync<U, R><U>(fn: (v: L) => EitherAsync<U, R>): EitherAsync<U, R><U>(promise: Promise<Either<U, R>>): EitherAsync<U, R> | Chains on left; short-circuits on right |
| .then() | PromiseLike.then | Allows await and .then() interop |
Try<T>
| | Signature | Description |
|---|---|---|
| Try.run | run<T>(fn: () => T): Try<T> | Runs a sync function, catches exceptions |
| Try.runAsync | runAsync<T>(fn: () => PromiseLike<T>): TryAsync<T> | Runs an async function (or any PromiseLike), catches exceptions — returns TryAsync |
| .isSuccess() | (): boolean | true if no exception was thrown |
| .isFailure() | (): boolean | true if an exception was caught |
| .onFail() | (fn: (error: unknown) => T): T | Returns the value on success, calls handler on failure |
| .onFailAsync() | (fn: (error: unknown) => PromiseLike<T>): Promise<T> | Async failure handler — accepts any PromiseLike<T>, including EitherAsync<L, T> when T is Either<L, R> |
TryAsync<T>
| | Signature | Description |
|---|---|---|
| TryAsync.runAsync | runAsync<T>(fn: () => Promise<T>): TryAsync<T> | Runs an async function, catches throws and rejections |
| .onFailAsync() | (fn: (error: unknown) => PromiseLike<T>): Promise<T> | Returns the value on success, calls async handler on failure — accepts any PromiseLike<T>, including EitherAsync<L, T> when T is Either<L, R> |
| .then() | PromiseLike.then | Allows await interop — resolves to Try<T> |
Validate<L, R>
| | Signature | Description |
|---|---|---|
| Validate.valid | valid<L, R>(value: R): Validate<L, R> | Creates valid; throws if value is null |
| Validate.invalid | invalid<L, R>(errors: L[]): Validate<L, R> | Creates invalid; throws if null |
| .isValid() | (): boolean | true if valid |
| .isInvalid() | (): boolean | true if invalid |
| .unwrap() | (): R | Returns value; throws if invalid |
| .unwrapInvalid() | (): L[] | Returns errors; throws if valid |
| .map() | <U>(fn: (v: R) => U): Validate<L, U> | Transforms the valid value |
| .bind() | <U>(fn: (v: R) => Validate<L, U>): Validate<L, U> | Chains on valid |
| .combine() | (other: Validate<L, R>): Validate<L, R> | Merges two validations, accumulating errors |
| .combineAsync() | (other: Promise<Validate<L, R>>): Promise<Validate<L, R>> | Async combine |
Traversal functions
| Function | Signature | Description |
|---|---|---|
| traverseOption | <T>(items: Option<T>[]): Option<T[]> | Collects all some values; returns none on first none |
| traverseOptionAsync | <T>(items: Option<Promise<T>>[]): Promise<Option<T[]>> | Async version |
| traverseEither | <L, R>(items: Either<L, R>[]): Either<L, R[]> | Collects all right values; returns first left |
| traverseEitherAsync | <L, R>(items: Either<L, Promise<R>>[]): Promise<Either<L, R[]>> | Async version |
| sequenceValidate | <L, R>(validates: Validate<L, R>[]): Validate<L, R[]> | Sequences, accumulating all errors |
| sequenceValidateAsync | <L, R>(validates: Promise<Validate<L, R>[]>): Promise<Validate<L, R[]>> | Async version |
Unit
import { Unit } from 'mini-fp';
Unit.default; // the singleton Unit valueDesign notes
mini-fp follows the same conventions as language-ext:
- Right-biased Either —
mapandbindoperate on the right (success) side by default;mapLeft/bindLeftlet you work on the left side when needed. - Fail-fast vs. error-harvesting —
EitherandOptionshort-circuit on the first failure;Validateaccumulates all failures. Choose based on whether you want to stop early or report everything. - No null leakage — constructors throw on
null/undefinedinputs (exceptfromandFromAsyncwhich are the safe entry points). Once inside a monad, the value is guaranteed non-null. - Async transparency — every synchronous operation has an
Asynccounterpart with the same semantics, so you can mix sync and async pipelines freely.
Development
# Run tests
npm test
# Watch mode
npm run test:watch
# Build
npm run buildThanks
A special thanks to Franco, who developed Tiny-FP, from which this repository borrows its name and through which I was introduced to functional programming for the first time.
If you're interested in functional programming and Railway-oriented programming, here are the first two resources I used when studying these paradigms — written for C#, but the concepts translate directly.
