| name | purify |
| description | Master the Purify library for practical functional programming in TypeScript with algebraic data types (Maybe, Either, EitherAsync, MaybeAsync), composable error handling, data transformations, Codec for runtime type safety, List operations, Tuple utilities, and functional patterns. Use when working with Purify's Maybe/Either types, async error handling with EitherAsync/MaybeAsync, runtime validation with Codecs, or building practical functional TypeScript applications with cleaner syntax than fp-ts. |
Purify Mastery
Purify is a practical functional library for TypeScript focusing on ergonomics and real-world usability, offering algebraic data types and tools for composable error handling and data transformations.
Installation and Setup
npm install purify-ts
import { Maybe, Either, EitherAsync, MaybeAsync } from 'purify-ts';
import { Codec, string, number } from 'purify-ts/Codec';
import { List } from 'purify-ts/List';
import { Tuple } from 'purify-ts/Tuple';
Philosophy
Purify focuses on:
- Practicality: Easier learning curve than fp-ts
- Ergonomics: Cleaner syntax, method chaining
- Real-world use cases: Built for production TypeScript
- Type safety: Leverages TypeScript's type system
- Async-first: First-class async support with EitherAsync/MaybeAsync
Maybe Type
Represents optional values safely.
Construction
import { Maybe, Just, Nothing } from 'purify-ts';
const just = Just(42);
const nothing = Nothing;
const fromNullable = Maybe.fromNullable(maybeValue);
const fromPredicate = Maybe.fromPredicate(
(n: number) => n > 0,
5
);
Maybe.fromFalsy(0);
Maybe.fromFalsy('hello');
Maybe.fromFalsy('');
Core Operations
Just(5)
.map(n => n * 2);
Nothing
.map(n => n * 2);
Just(5)
.chain(n => n > 0 ? Just(n * 2) : Nothing);
Just(5)
.map(n => n * 2)
.chain(n => Just(n + 1))
.map(n => n.toString());
Just(5).orDefault(0);
Nothing.orDefault(0);
Just(5).caseOf({
Just: n => `Value: ${n}`,
Nothing: () => 'No value'
});
Just(5)
.filter(n => n > 3);
Just(2)
.filter(n => n > 3);
Nothing
.alt(Just(42));
Just((n: number) => n * 2)
.ap(Just(5));
Utility Methods
const maybe = Just(5);
if (maybe.isJust()) {
const value = maybe.extract();
}
Just(5).extract();
Just(5).toEither('error');
Nothing.toEither('error');
Just(5).ifJust(n => console.log(n));
Nothing.ifNothing(() => console.log('Empty'));
Maybe.sequence([Just(1), Just(2), Just(3)]);
Maybe.sequence([Just(1), Nothing, Just(3)]);
Maybe.catMaybes([Just(1), Nothing, Just(3)]);
Maybe.mapMaybe(
(n: number) => n > 0 ? Just(n * 2) : Nothing,
[1, -1, 2, -2, 3]
);
Maybe.encase(() => JSON.parse(jsonString));
Practical Examples
const head = <A>(arr: A[]): Maybe<A> =>
Maybe.fromNullable(arr[0]);
const getProp = <T, K extends keyof T>(obj: T, key: K): Maybe<T[K]> =>
Maybe.fromNullable(obj[key]);
type User = { name: string; address?: { city?: string } };
const getCity = (user: User): Maybe<string> =>
Maybe.fromNullable(user.address)
.chain(addr => Maybe.fromNullable(addr.city));
const parseNumber = (s: string): Maybe<number> =>
Maybe.encase(() => {
const n = parseFloat(s);
if (isNaN(n)) throw new Error('Not a number');
return n;
});
const extractUserEmail = (data: unknown): Maybe<string> =>
Maybe.fromNullable(data)
.chain(d => typeof d === 'object' ? Just(d as any) : Nothing)
.chain(obj => Maybe.fromNullable(obj.user))
.chain(user => Maybe.fromNullable(user.email))
.filter(email => typeof email === 'string' && email.includes('@'));
Either Type
Represents computations that can fail.
Construction
import { Either, Left, Right } from 'purify-ts';
const right = Right(42);
const left = Left('error');
const fromPredicate = Either.fromPredicate(
(n: number) => n > 0,
n => `${n} is not positive`,
5
);
Either.encase(() => JSON.parse(jsonString));
Core Operations
Right(5)
.map(n => n * 2);
Left('error')
.map(n => n * 2);
Left('error')
.mapLeft(e => e.toUpperCase());
Right(5)
.chain(n => n > 0 ? Right(n * 2) : Left('negative'));
Right(5)
.map(n => n * 2)
.chain(n => Right(n + 1))
.map(n => n.toString());
Right(5).caseOf({
Left: error => `Error: ${error}`,
Right: value => `Success: ${value}`
});
Right(5).orDefault(0);
Left('error').orDefault(0);
Left('error').orDefaultLazy(() => expensiveComputation());
Right(5).swap();
Left('error').swap();
Right<string, number>(5).bimap(
e => e.toUpperCase(),
n => n * 2
);
Left('error')
.alt(Right(42));
Error Handling Patterns
const safeParse = (json: string): Either<Error, unknown> =>
Either.encase(() => JSON.parse(json));
const validateEmail = (email: string): Either<string, string> =>
email.includes('@')
? Right(email)
: Left('Invalid email');
const validateAge = (age: number): Either<string, number> =>
age >= 18
? Right(age)
: Left('Must be 18 or older');
const validateUser = (email: string, age: number): Either<string, User> =>
validateEmail(email)
.chain(validEmail =>
validateAge(age).map(validAge => ({
email: validEmail,
age: validAge
}))
);
Either.sequence([
validateEmail('test@example.com'),
validateAge(25).mapLeft(() => 'Age validation failed')
]);
const eithers = [Right(1), Left('error'), Right(2)];
Either.lefts(eithers);
Either.rights(eithers);
const either = Right(5);
if (either.isRight()) {
const value = either.extract();
}
Right(5).extract();
Left('error').extractLeft();
Right(5).toMaybe();
Left('error').toMaybe();
Right(5).ifRight(n => console.log(n));
Left('error').ifLeft(e => console.error(e));
Practical Examples
type ApiError = { message: string; code: number };
const parseApiResponse = <T>(
response: Response
): Either<ApiError, T> =>
response.ok
? Either.encase(() => response.json())
.mapLeft(e => ({
message: 'Parse error',
code: 500
}))
: Left({ message: 'Request failed', code: response.status });
type ValidationError = { field: string; message: string };
const validateForm = (
email: string,
age: number,
name: string
): Either<ValidationError[], User> => {
const errors: ValidationError[] = [];
if (!email.includes('@')) {
errors.push({ field: 'email', message: 'Invalid email' });
}
if (age < 18) {
errors.push({ field: 'age', message: 'Must be 18+' });
}
if (name.length === 0) {
errors.push({ field: 'name', message: 'Required' });
}
return errors.length > 0
? Left(errors)
: Right({ email, age, name });
};
const processUser = (data: unknown): Either<string, string> =>
parseUserData(data)
.chain(validateUser)
.map(normalizeUser)
.chain(saveUser)
.map(formatResponse);
EitherAsync Type
Async operations with error handling.
Construction
import { EitherAsync } from 'purify-ts';
const fetchUser = (id: number): EitherAsync<Error, User> =>
EitherAsync(async ({ liftEither, throwE }) => {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return throwE(new Error('Not found'));
}
return response.json();
} catch (error) {
return throwE(error as Error);
}
});
const fromPromise = EitherAsync.fromPromise(
async () => fetch('/api/data').then(r => r.json())
);
const eitherAsync = EitherAsync.liftEither(
validateData(data)
);
Core Operations
fetchUser(1)
.map(user => user.name);
fetchUser(1)
.mapLeft(error => ({
message: error.message,
code: 500
}));
fetchUser(1)
.chain(user => fetchPosts(user.id));
fetchUser(1)
.map(user => user.id)
.chain(fetchPosts)
.map(posts => posts.length);
await fetchUser(1).run();
await fetchUser(1)
.run()
.then(either => either.caseOf({
Left: error => console.error(error),
Right: user => console.log(user)
}));
EitherAsync<Error, Result>(async ({ liftEither, fromPromise, throwE }) => {
const validated = await liftEither(validateData(data));
const result = await fromPromise(fetch('/api/data'));
if (condition) {
return throwE(new Error('Failed'));
}
return result;
});
Practical Examples
const fetchValidatedUser = (id: number): EitherAsync<Error, User> =>
EitherAsync(async ({ liftEither, throwE }) => {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return throwE(new Error(`HTTP ${response.status}`));
}
const data = await response.json();
const validated = await liftEither(validateUser(data));
return validated;
});
const fetchUserData = (id: number): EitherAsync<Error, UserData> =>
EitherAsync.liftEither(
Either.sequence([
Right(fetchUser(id)),
Right(fetchPosts(id)),
Right(fetchComments(id))
])
).chain(async ([userAsync, postsAsync, commentsAsync]) => {
const [user, posts, comments] = await Promise.all([
userAsync.run(),
postsAsync.run(),
commentsAsync.run()
]);
return Either.sequence([user, posts, comments])
.map(([u, p, c]) => ({ user: u, posts: p, comments: c }));
});
const fetchWithRetry = <T>(
fetch: EitherAsync<Error, T>,
retries: number = 3
): EitherAsync<Error, T> =>
EitherAsync(async ({ throwE }) => {
let lastError: Error | null = null;
for (let i = 0; i < retries; i++) {
const result = await fetch.run();
if (result.isRight()) {
return result.extract();
}
lastError = result.extractLeft();
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
}
return throwE(lastError!);
});
const createUserTransaction = (
userData: UserData
): EitherAsync<Error, User> =>
EitherAsync(async ({ liftEither, throwE }) => {
const validated = await liftEither(validateUserData(userData));
const user = await liftEither(
await createUser(validated).run()
);
const emailResult = await sendWelcomeEmail(user.email).run();
if (emailResult.isLeft()) {
await deleteUser(user.id).run();
return throwE(new Error('Failed to send email'));
}
return user;
});
MaybeAsync Type
Async operations that may not return a value.
Construction and Usage
import { MaybeAsync } from 'purify-ts';
const findUser = (id: number): MaybeAsync<User> =>
MaybeAsync(async ({ liftMaybe }) => {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) return liftMaybe(Nothing);
const data = await response.json();
return liftMaybe(Maybe.fromNullable(data));
});
findUser(1)
.map(user => user.name)
.chain(name => findUserByName(name));
await findUser(1).run();
findUser(1)
.toEitherAsync(new Error('User not found'));
const getCachedValue = <T>(key: string): MaybeAsync<T> =>
MaybeAsync(async ({ liftMaybe }) => {
const value = await cache.get(key);
return liftMaybe(Maybe.fromNullable(value));
});
const getOrFetch = <T>(
key: string,
fetch: () => Promise<T>
): MaybeAsync<T> =>
getCachedValue<T>(key)
.alt(MaybeAsync(async () => {
const value = await fetch();
await cache.set(key, value);
return value;
}));
Codec - Runtime Type Validation
Type-safe encoding/decoding with runtime validation.
Basic Codecs
import { Codec, string, number, boolean, nullType } from 'purify-ts/Codec';
import { Left, Right } from 'purify-ts';
string.decode('hello');
string.decode(123);
number.decode(42);
boolean.decode(true);
nullType.decode(null);
const positiveNumber = Codec.custom<number>({
decode: (input) =>
typeof input === 'number' && input > 0
? Right(input)
: Left('Expected positive number'),
encode: (input) => input
});
Object Codecs
import { GetType } from 'purify-ts/Codec';
const UserCodec = Codec.interface({
id: number,
name: string,
email: string,
age: number
});
type User = GetType<typeof UserCodec>;
const result = UserCodec.decode({
id: 1,
name: 'John',
email: 'john@example.com',
age: 30
});
const invalid = UserCodec.decode({
id: 1,
name: 'John'
});
UserCodec.encode(user);
Advanced Codecs
import { array, optional, oneOf, exactly, lazy } from 'purify-ts/Codec';
const NumberArrayCodec = array(number);
NumberArrayCodec.decode([1, 2, 3]);
const UserWithOptionalEmailCodec = Codec.interface({
id: number,
name: string,
email: optional(string)
});
const StatusCodec = oneOf([
exactly('pending'),
exactly('active'),
exactly('completed')
]);
type Status = GetType<typeof StatusCodec>;
const ShapeCodec = oneOf([
Codec.interface({
type: exactly('circle'),
radius: number
}),
Codec.interface({
type: exactly('rectangle'),
width: number,
height: number
})
]);
interface TreeNode {
value: number;
children: TreeNode[];
}
const TreeNodeCodec: Codec<TreeNode> = Codec.interface({
value: number,
children: lazy(() => array(TreeNodeCodec))
});
import { record } from 'purify-ts/Codec';
const StringToNumberCodec = record(number);
StringToNumberCodec.decode({ a: 1, b: 2 });
const AddressCodec = Codec.interface({
street: string,
city: string,
zipCode: string
});
const PersonCodec = Codec.interface({
name: string,
age: number,
address: AddressCodec
});
Custom Validation
const emailCodec = string.compose(
Codec.custom<string>({
decode: (email) =>
email.includes('@')
? Right(email)
: Left('Invalid email format'),
encode: (email) => email
})
);
const PasswordCodec = string.compose(
Codec.custom<string>({
decode: (password) => {
if (password.length < 8) {
return Left('Password must be at least 8 characters');
}
if (!/[A-Z]/.test(password)) {
return Left('Password must contain uppercase letter');
}
if (!/[0-9]/.test(password)) {
return Left('Password must contain number');
}
return Right(password);
},
encode: (password) => password
})
);
const NonEmptyStringCodec = string.compose(
Codec.custom<string>({
decode: (s) =>
s.length > 0 ? Right(s) : Left('String cannot be empty'),
encode: (s) => s
})
);
Practical Codec Patterns
const ApiResponseCodec = Codec.interface({
data: UserCodec,
meta: Codec.interface({
page: number,
total: number
})
});
const fetchUser = async (id: number): Promise<Either<string, User>> => {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return ApiResponseCodec.decode(data)
.mapLeft(error => `Validation failed: ${error}`)
.map(result => result.data);
};
const SignupFormCodec = Codec.interface({
email: emailCodec,
password: PasswordCodec,
age: positiveNumber,
terms: exactly(true)
});
const validateSignupForm = (
data: unknown
): Either<string, SignupForm> =>
SignupFormCodec.decode(data);
const EnvCodec = Codec.interface({
NODE_ENV: oneOf([
exactly('development'),
exactly('production'),
exactly('test')
]),
PORT: number,
DATABASE_URL: string,
API_KEY: NonEmptyStringCodec
});
const env = EnvCodec.decode(process.env).unsafeCoerce();
const DateCodec = Codec.custom<Date>({
decode: (input) => {
if (typeof input !== 'string') {
return Left('Expected ISO date string');
}
const date = new Date(input);
return isNaN(date.getTime())
? Left('Invalid date')
: Right(date);
},
encode: (date) => date.toISOString()
});
const EventCodec = Codec.interface({
name: string,
date: DateCodec,
attendees: array(UserCodec)
});
List Type
Functional list operations.
import { List } from 'purify-ts/List';
const list = List.of(1, 2, 3, 4, 5);
const fromArray = List([1, 2, 3]);
list.head();
list.tail();
list
.map(n => n * 2)
.filter(n => n > 5);
list.reduce((acc, n) => acc + n, 0);
list.find(n => n > 3);
list.toArray();
list.cons(0);
list.at(2);
list.at(10);
list.length();
list.isEmpty();
list.reverse();
list.take(3);
list.drop(2);
list.zip(List.of('a', 'b', 'c'));
Tuple Type
Fixed-size heterogeneous collections.
import { Tuple } from 'purify-ts/Tuple';
const tuple = Tuple(1, 'hello', true);
tuple.fst();
tuple.snd();
Tuple(1, 'hello').map(s => s.toUpperCase());
Tuple(1, 'hello').mapFirst(n => n * 2);
Tuple(1, 'hello').bimap(
n => n * 2,
s => s.toUpperCase()
);
Tuple(1, 'hello').swap();
Tuple(1, 'hello', true).toArray();
Tuple.fanout(
(n: number) => n * 2,
(n: number) => n + 1
)(5);
Function Utilities
import { identity, constant, compose } from 'purify-ts/Function';
identity(5);
const alwaysFive = constant(5);
alwaysFive();
alwaysFive(10);
const double = (n: number) => n * 2;
const increment = (n: number) => n + 1;
const toString = (n: number) => n.toString();
const process = compose(toString, increment, double);
process(5);
Non-Empty Lists and Arrays
Type-safe non-empty collections.
import { NonEmptyList } from 'purify-ts/NonEmptyList';
const nel = NonEmptyList.fromArray([1, 2, 3]);
const empty = NonEmptyList.fromArray([]);
nel.map(list => list.head());
nel.map(list => list.last());
const getFirst = (nel: NonEmptyList<number>): number =>
nel.head();
Practical Patterns
API Client with Error Handling
type ApiError =
| { type: 'network'; message: string }
| { type: 'validation'; errors: string[] }
| { type: 'server'; status: number; message: string };
class ApiClient {
constructor(private baseUrl: string) {}
request<T>(
path: string,
options?: RequestInit
): EitherAsync<ApiError, T> {
return EitherAsync(async ({ throwE }) => {
try {
const response = await fetch(`${this.baseUrl}${path}`, options);
if (!response.ok) {
return throwE({
type: 'server',
status: response.status,
message: await response.text()
});
}
return await response.json();
} catch (error) {
return throwE({
type: 'network',
message: error instanceof Error ? error.message : 'Unknown error'
});
}
});
}
get<T>(path: string): EitherAsync<ApiError, T> {
return this.request<T>(path);
}
post<T>(path: string, body: unknown): EitherAsync<ApiError, T> {
return this.request<T>(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
}
}
const api = new ApiClient('https://api.example.com');
const createUser = (userData: UserData): EitherAsync<ApiError, User> =>
api.post<unknown>('/users', userData)
.chain(data =>
EitherAsync.liftEither(
UserCodec.decode(data).mapLeft(error => ({
type: 'validation',
errors: [error]
}))
)
);
Form Validation with Codecs
const LoginFormCodec = Codec.interface({
email: string.compose(
Codec.custom<string>({
decode: (email) =>
email.includes('@')
? Right(email)
: Left('Invalid email'),
encode: identity
})
),
password: string.compose(
Codec.custom<string>({
decode: (password) =>
password.length >= 8
? Right(password)
: Left('Password must be at least 8 characters'),
encode: identity
})
)
});
type LoginForm = GetType<typeof LoginFormCodec>;
const validateLoginForm = (data: unknown): Either<string, LoginForm> =>
LoginFormCodec.decode(data);
const handleSubmit = (data: unknown) => {
validateLoginForm(data).caseOf({
Left: error => setError(error),
Right: form => submitLogin(form)
});
};
Async Pipeline with EitherAsync
const processUserSignup = (
signupData: unknown
): EitherAsync<string, User> =>
EitherAsync.liftEither(
SignupFormCodec.decode(signupData)
.mapLeft(error => `Validation error: ${error}`)
)
.chain(form =>
EitherAsync(async ({ liftEither, throwE }) => {
const exists = await checkEmailExists(form.email).run();
if (exists.extract()) {
return throwE('Email already registered');
}
const hashedPassword = await hashPassword(form.password);
const user = await liftEither(
await createUser({
...form,
password: hashedPassword
}).run()
);
await sendWelcomeEmail(user.email).run();
return user;
})
);
await processUserSignup(formData)
.run()
.then(result => result.caseOf({
Left: error => console.error(error),
Right: user => console.log('Created:', user)
}));
Caching with MaybeAsync
class Cache<T> {
private store = new Map<string, T>();
get(key: string): MaybeAsync<T> {
return MaybeAsync(async ({ liftMaybe }) => {
const value = this.store.get(key);
return liftMaybe(Maybe.fromNullable(value));
});
}
set(key: string, value: T): Promise<void> {
this.store.set(key, value);
return Promise.resolve();
}
getOrFetch<E>(
key: string,
fetch: () => EitherAsync<E, T>
): EitherAsync<E, T> {
return this.get(key)
.toEitherAsync(null as any)
.alt(
fetch().chainFirst(value =>
EitherAsync.fromPromise(() => this.set(key, value))
)
);
}
}
const cache = new Cache<User>();
const getUser = (id: number): EitherAsync<Error, User> =>
cache.getOrFetch(
`user:${id}`,
() => fetchUser(id)
);
Best Practices
- Use method chaining: Purify's API is designed for readability through chaining
- Leverage Codecs: Use Codecs for all external data validation
- Prefer EitherAsync over Promise: Explicit error handling prevents surprises
- Use caseOf for exhaustive handling: Pattern matching ensures all cases covered
- Type-safe with GetType: Derive TypeScript types from Codecs
- Lazy defaults with orDefaultLazy: Avoid expensive computations in happy path
- Chain with flatMap: Avoid nested Maybe/Either with chain
- Use NonEmptyList: When guaranteed non-empty, use type system to enforce
- Sequence for all-or-nothing: Use Maybe.sequence and Either.sequence
- Test with property-based testing: Algebraic laws make great properties
Comparison with fp-ts
Purify advantages:
- Cleaner, more ergonomic API
- Method chaining feels more natural
- Built-in Codec system for validation
- Easier learning curve
- Better async support with EitherAsync/MaybeAsync
fp-ts advantages:
- More comprehensive type class system
- Better for advanced FP patterns
- Larger ecosystem
- Integration with Effect-TS
- More generic abstractions
Choose Purify when:
- Team new to functional programming
- Need practical, production-ready code quickly
- Want built-in validation with Codecs
- Prefer method chaining syntax
Choose fp-ts when:
- Need advanced FP abstractions
- Building on Effect-TS ecosystem
- Team comfortable with Haskell/Scala concepts
- Need maximum type safety
Migration Tips
From Promises to EitherAsync
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('Not found');
return response.json();
}
function fetchUser(id: number): EitherAsync<Error, User> {
return EitherAsync(async ({ throwE }) => {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) return throwE(new Error('Not found'));
return response.json();
});
}
From null/undefined to Maybe
function getFirstName(user: User | null): string | null {
return user?.name?.split(' ')[0] ?? null;
}
function getFirstName(user: User | null): Maybe<string> {
return Maybe.fromNullable(user)
.chain(u => Maybe.fromNullable(u.name))
.map(name => name.split(' ')[0]);
}
From try-catch to Either
function parseJSON<T>(json: string): T {
try {
return JSON.parse(json);
} catch (error) {
throw new Error(`Parse failed: ${error}`);
}
}
function parseJSON<T>(json: string): Either<Error, T> {
return Either.encase(() => JSON.parse(json))
.mapLeft(error => new Error(`Parse failed: ${error}`));
}