@nicolastoulemont/std
v0.7.2
Published
Collection of utility functions
Readme
@nicolastoulemont/std
Introduction
@nicolastoulemont/std is a functional TypeScript toolkit for modeling domain data, handling failures explicitly, and composing sync or async workflows.
It is designed for application code where clear control flow, predictable typing, and dependency-aware orchestration matter.
The API is small, pipe-friendly, and built around practical primitives you can combine incrementally.
Installation
pnpm add @nicolastoulemont/stdQuick Start
import { Result, Data, pipe } from "@nicolastoulemont/std"
class InvalidPortError extends Data.TaggedError("InvalidPortError")<{ input: string }> {}
const parsePort = (input: string) =>
pipe(
Result.try(() => Number.parseInt(input, 10)),
Result.filter(
(n) => Number.isInteger(n) && n > 0,
() => new InvalidPortError({ input }),
),
)import { Fx, Layer, Provide, Service, pipe } from "@nicolastoulemont/std"
const Config = Service.tag<{ baseUrl: string }>("Config")
const ConfigLive = Layer.ok(Config, { baseUrl: "https://api.example.com" })
const program = Fx.gen(function* () {
const config = yield* Config
return config.baseUrl
})
const exit = Fx.run(pipe(program, Provide.layer(ConfigLive)))
const response = Fx.match(exit, {
Ok: (ok) => ({ status: 200, body: ok.value }),
Err: (err) => ({ status: 400, body: err.error }),
Defect: (defect) => ({ status: 500, body: String(defect.defect) }),
})Main Modules
Result
Result models success/failure with typed errors so transformations stay explicit and composable.
Abstract Example
import { Result, Data, pipe } from "@nicolastoulemont/std"
class NotPositiveIntegerError extends Data.TaggedError("NotPositiveIntegerError")<{ input: string }> {}
const parsePositiveInt = (input: string) => {
const parsed = Number.parseInt(input, 10)
return pipe(
Result.ok(parsed),
Result.filter(
(n) => Number.isInteger(n) && n > 0,
() => new NotPositiveIntegerError({ input }),
),
)
}Real-World Example
import { Result, Data, pipe } from "@nicolastoulemont/std"
class ValidationError extends Data.TaggedError("ValidationError")<{ message: string }> {}
class ConflictError extends Data.TaggedError("ConflictError")<{ message: string }> {}
type SignupError = ValidationError | ConflictError
const validateEmail = (email: string) =>
email.includes("@") ? Result.ok(email) : Result.err<SignupError>(new ValidationError({ message: "Invalid email" }))
const createUser = (email: string) =>
email === "[email protected]"
? Result.err<SignupError>(new ConflictError({ message: "Email already used" }))
: Result.ok({ id: "u_123", email })
const signup = (email: string) => pipe(validateEmail(email), Result.flatMap(createUser))Option
Option models optional presence/absence when missing data is expected and not an error condition.
Abstract Example
import { Option, pipe } from "@nicolastoulemont/std"
const normalizedName = (value: string | undefined) =>
pipe(
Option.fromNullable(value),
Option.map((name) => name.trim()),
Option.filter((name) => name.length > 0),
Option.unwrapOr("Anonymous"),
)Real-World Example
import { Option, pipe } from "@nicolastoulemont/std"
const readPagination = (query: URLSearchParams) => ({
page: pipe(
Option.fromNullable(query.get("page")),
Option.map((raw) => Number.parseInt(raw, 10)),
Option.filter((n) => Number.isInteger(n) && n > 0),
Option.unwrapOr(1),
),
limit: pipe(
Option.fromNullable(query.get("limit")),
Option.map((raw) => Number.parseInt(raw, 10)),
Option.filter((n) => Number.isInteger(n) && n > 0 && n <= 100),
Option.unwrapOr(20),
),
})Either
Either models two valid branches where both sides are meaningful outcomes rather than success versus failure.
Abstract Example
import { Either } from "@nicolastoulemont/std"
const parseSource = (input: "local" | "remote") => (input === "local" ? Either.left("LOCAL") : Either.right("REMOTE"))
const label = Either.match(parseSource("local"), {
Left: (source) => `Source: ${source}`,
Right: (source) => `Source: ${source}`,
})Real-World Example
import { Either, pipe } from "@nicolastoulemont/std"
type Source = "cache" | "database"
type User = { id: string; name: string }
const findUser = (id: string) =>
id.startsWith("cached:") ? Either.left<Source, User>("cache") : Either.right<Source, User>({ id, name: "Ada" })
const responseMeta = (id: string) =>
pipe(
findUser(id),
Either.match({
Left: (source) => ({ source, stale: true }),
Right: (user) => ({ source: "database" as const, stale: false, user }),
}),
)Fx
Fx models generator-based effects with typed dependencies and short-circuiting typed failures.
Abstract Example
import { Fx, Layer, Provide, Service, pipe } from "@nicolastoulemont/std"
const Clock = Service.tag<{ now: () => number }>("Clock")
const ClockLive = Layer.ok(Clock, { now: () => Date.now() })
const program = Fx.gen(function* () {
const clock = yield* Clock
return clock.now()
})
const exit = Fx.run(pipe(program, Provide.layer(ClockLive)))
const timestamp = Fx.match(exit, {
Ok: (ok) => ok.value,
Err: () => 0,
Defect: () => 0,
})Real-World Example
import { Fx, Layer, Result, Data, Provide, Service, pipe } from "@nicolastoulemont/std"
const Api = Service.tag<{ postOrder: (input: { sku: string; qty: number }) => Promise<{ orderId: string }> }>("Api")
const ApiLive = Layer.ok(Api, {
postOrder: async () => ({ orderId: "ord_42" }),
})
class InvalidQuantityError extends Data.TaggedError("InvalidQuantityError")<{ qty: number }> {}
const submitOrder = Fx.gen(function* (payload: { sku?: string; qty: number }) {
const api = yield* Api
const sku = yield* Fx.option(payload.sku)
const validQty = yield* Result.filter(
Result.ok(payload.qty),
(qty) => qty > 0,
(qty) => new InvalidQuantityError({ qty }),
)
return yield* Fx.try(() => api.postOrder({ sku, qty: validQty }))
})
const exit = Fx.run(pipe(submitOrder({ sku: "book-1", qty: 2 }), Provide.layer(ApiLive)))
const httpResponse = Fx.match(exit, {
Ok: (ok) => ({ status: 201, body: ok.value }),
Err: (err) => ({ status: 400, body: String(err.error) }),
Defect: () => ({ status: 500, body: "Unexpected defect" }),
})Retry Example
import { Fx, Result, Schedule } from "@nicolastoulemont/std"
let attempts = 0
const flaky = Fx.gen(function* () {
attempts += 1
if (attempts < 3) {
return yield* Result.err("temporary" as const)
}
return "ok"
})
const exit = Fx.run(Fx.retry(flaky, Schedule.recurs(5)))Nested Retry with Dependencies
import { Fx, Layer, Result, Schedule, pipe, Provide, Service } from "@nicolastoulemont/std"
type ConfigService = { baseUrl: string }
const Config = Service.tag<ConfigService>("Config")
const ConfigLive = Layer.ok(Config, { baseUrl: "https://api.example.com" })
let attempts = 0
const inner = Fx.retry(
Fx.gen(function* () {
const config = yield* Config
attempts += 1
if (attempts < 2) {
return yield* Result.err("temporary" as const)
}
return config.baseUrl
}),
Schedule.recurs(2),
)
const program = Fx.gen(function* () {
const baseUrl = yield* inner
return `ready:${baseUrl}`
})
const exit = Fx.run(pipe(program, Provide.layer(ConfigLive)))Concurrent Traversal with Fx.forEach
import { Fx } from "@nicolastoulemont/std"
const loadUsers = Fx.forEach(
["u1", "u2", "u3"],
(id) =>
Fx.gen(async function* () {
const response = await fetch(`/api/users/${id}`)
return yield* Fx.try(() => response.json())
}),
{ concurrency: 2 },
)
const exit = await Fx.run(loadUsers)Queue
Queue provides a standalone FIFO task queue with configurable concurrency, backpressure (bounded mode), and lifecycle controls.
Abstract Example
import { Queue } from "@nicolastoulemont/std"
const queue = Queue.make({ concurrency: 2 })
const first = queue.enqueue(() => 1)
const second = queue.enqueue(async () => 2)
await queue.awaitIdle()
await queue.shutdown({ mode: "drain" })Real-World Example
import { Queue } from "@nicolastoulemont/std"
const imageQueue = Queue.bounded(100, { concurrency: 4 })
const tasks = imageUrls.map((url) =>
imageQueue.enqueue(async ({ signal }) => {
const response = await fetch(url, { signal })
return response.arrayBuffer()
}),
)
const buffers = await Promise.all(tasks)
await imageQueue.shutdown({ mode: "drain" })Multithread
Multithread runs self-contained callbacks in worker threads using a Result-first API while remaining yieldable in Fx.gen.
Abstract Example
import { Multithread } from "@nicolastoulemont/std"
const op = Multithread.run((input: string, ctx) => {
ctx.throwIfCancelled()
return input.toUpperCase()
}, "hello")
const result = await op.result()Real-World Example
import { Fx, Multithread } from "@nicolastoulemont/std"
const program = Fx.gen(async function* () {
const records = yield* Multithread.map(
['{"id":"1","email":"[email protected]"}', '{"id":"2","email":"[email protected]"}'],
(line, _index, ctx) => {
ctx.throwIfCancelled()
try {
return JSON.parse(line) as { id: string; email: string }
} catch {
return { _tag: "Err" as const, error: { _tag: "ParseError" as const, line } }
}
},
{ parallelism: 4 },
)
const preferred = yield* Multithread.firstSuccess([Multithread.run(() => "cache"), Multithread.run(() => "database")])
return { records, preferred }
})
const exit = await Fx.run(program)Multithread cancellation is cooperative. abort() always cancels logically, and worker code can stop early by calling ctx.throwIfCancelled().
Adt
Adt provides schema-backed tagged variants so you can model domain state with exhaustive pattern matching.
Abstract Example
import { Adt, type AdtInfer } from "@nicolastoulemont/std"
import { z } from "zod"
const Shape = Adt.union("Shape", {
Circle: z.object({ radius: z.number() }),
Square: z.object({ side: z.number() }),
})
type Shape = AdtInfer<typeof Shape>
const describeShape = (shape: Shape) =>
Adt.match(shape, {
Circle: (value) => `circle(${value.radius})`,
Square: (value) => `square(${value.side})`,
})Real-World Example
import { Adt, type AdtInfer } from "@nicolastoulemont/std"
import { z } from "zod"
const OrderState = Adt.union("OrderState", {
Draft: z.object({ id: z.string() }),
Confirmed: z.object({ id: z.string(), paymentId: z.string() }),
Shipped: z.object({ id: z.string(), trackingId: z.string() }),
})
type OrderState = AdtInfer<typeof OrderState>
const badgeLabel = (state: OrderState) =>
Adt.match(state, {
Draft: () => "Waiting for payment",
Confirmed: () => "Preparing shipment",
Shipped: (value) => `Shipped: ${value.trackingId}`,
})Data
Data creates immutable structural value objects (Data.struct, Data.tuple, Data.array, Data.tagged) with stable equality and hashing semantics.
Abstract Example
import { Data } from "@nicolastoulemont/std"
const a = Data.struct({ env: "prod", retries: 3 })
const b = Data.struct({ env: "prod", retries: 3 })
const same = a.equals(b) // trueReal-World Example
import { Data } from "@nicolastoulemont/std"
const previous = Data.struct({ search: "books", sort: "price-asc" })
const next = Data.struct({ search: "books", sort: "price-asc" })
if (previous.equals(next)) {
// Skip redundant fetch because filter state is structurally identical
}Order
Order provides composable comparators and immutable sorting helpers.
Abstract Example
import { Order, pipe } from "@nicolastoulemont/std"
type User = { name: string; age: number }
const byAge = Order.by(Order.number, (user: User) => user.age)
const byName = Order.by(Order.string, (user: User) => user.name)
const userOrder = Order.merge(byAge, byName)
const sameOrder = pipe(byAge, Order.merge(byName))
const allOrders = Order.merge([byAge, byName])
const sorted = Order.sort(
[
{ name: "bob", age: 30 },
{ name: "alice", age: 30 },
{ name: "zoe", age: 25 },
],
allOrders,
)Real-World Example
import { Order } from "@nicolastoulemont/std"
type Product = {
id: string
category: string
price: number
rating: number
}
const byCategory = Order.by(Order.string, (product: Product) => product.category)
const byPrice = Order.by(Order.number, (product: Product) => product.price)
const byRatingDesc = Order.reverse(Order.by(Order.number, (product: Product) => product.rating))
const sortProducts = Order.sortBy(byCategory, byPrice, byRatingDesc)
const products: Product[] = [
{ id: "a", category: "books", price: 20, rating: 4.8 },
{ id: "b", category: "books", price: 20, rating: 4.5 },
{ id: "c", category: "games", price: 60, rating: 4.7 },
]
const sorted = sortProducts(products)pipe / flow
pipe and flow compose sync/async transformations into readable, type-inferred data pipelines.
Abstract Example
import { pipe, flow } from "@nicolastoulemont/std"
const toLabel = flow(
(n: number) => n * 2,
(n) => n.toString(),
(s) => `value:${s}`,
)
const result = pipe(10, (n) => n + 1, toLabel) // "value:22"Real-World Example
import { pipe } from "@nicolastoulemont/std"
type RawProfile = { name?: string; age?: string }
const normalizeProfile = (input: RawProfile) =>
pipe(
input,
(p) => ({ name: p.name?.trim() ?? "", age: Number.parseInt(p.age ?? "0", 10) }),
(p) => ({ ...p, age: Number.isNaN(p.age) ? 0 : p.age }),
(p) => ({ ...p, isAdult: p.age >= 18 }),
)