Type-safe building blocks for real backends.

APIs, queues, workflows, ORM, and more - with zero runtime dependencies. Import only what you use.

Read the docs
semola/api/hello.ts
import { Api } from "semola/api";import { z } from "zod";const api = new Api();api.defineRoute({  path: "/hello/:name",  method: "GET",  request: {    params: z.object({ name: z.string() }),  },  response: {    200: z.object({ message: z.string() }),  },  handler: async (c) => {    return c.json(200, {      message: `Hello, ${c.req.params.name}!`,    });  },});api.serve(3000);

Showing API example from semola/api

Why teams reach for it

Small surface, clear tradeoffs, no surprise lock-in.

01

Type-safe by default

Inputs, outputs, and errors stay typed end to end. Catch mistakes at compile time instead of in production logs.

02

Easy to pick up

Small APIs, clear names, short examples. Read a snippet, paste it in, keep shipping.

03

One toolkit, not a pile

APIs, jobs, data, auth, and utilities in one package. Skip the ritual of wiring half a dozen libs for every new app.

In practice

Same shape every time: pick a module, read a short pitch, steal the snippet.

01semola/api

Typed routes, any schema

Define REST handlers with request and response schemas. Works with Zod, Valibot, ArkType, or any Standard Schema library.

Docs →
hello.ts
import { Api } from "semola/api";import { z } from "zod";const api = new Api();api.defineRoute({  path: "/hello/:name",  method: "GET",  request: {    params: z.object({ name: z.string() }),  },  response: {    200: z.object({ message: z.string() }),  },  handler: async (c) => {    return c.json(200, {      message: `Hello, ${c.req.params.name}!`,    });  },});api.serve(3000);
02semola/cache

Typed cache with TTL

Store and load JSON values in Redis. Optional prefix and per-key expiry without hand-rolling serialization.

Docs →
users.ts
import { Cache } from "semola/cache";const users = new Cache<{ name: string; email: string }>({  redis: redisClient,  ttl: 60_000,  prefix: "user",});await users.set("1", {  name: "Ada",  email: "ada@example.com",});const user = await users.get("1");
03semola/cli

Typed command-line programs

Nested commands, validated arguments, and options with Standard Schema. Parse argv or pass arrays in tests.

Docs →
program.ts
import { CLI } from "semola/cli";import { z } from "zod";const program = new CLI({  name: "semola",  version: "1.0.0",});program  .command("split")  .argument("str", { schema: z.string() })  .option("first", {    schema: z.boolean().default(false),    aliases: ["f"],  })  .action((args, options) => {    const parts = args.str.split(" ");    console.log(options.first ? parts[0] : parts);  });await program.parse();
04semola/cron

Schedules in-process

Run handlers on a cron expression or alias. Start, stop, and ask for the next fire time from one object.

Docs →
jobs.ts
import { Cron } from "semola/cron";const daily = new Cron({  name: "daily-report",  schedule: "@daily",  handler: async () => {    await sendReport();  },});daily.run();
05semola/errors

Errors as values

mightThrow turns promises into [error, data] tuples. Branch once, then use narrowed success data - no nested try/catch.

Docs →
fetch.ts
import { mightThrow } from "semola/errors";const [error, data] = await mightThrow(  fetch("https://api.example.com"),);if (error) {  console.error(error.message);  return;}console.log(data);
06semola/extra

Small helpers, big saves

Odds and ends that are useful but too small for their own package - like retries with full-jitter backoff.

Docs →
retry.ts
import { createRetry } from "semola/extra";const fetchUser = createRetry(  async (id: string) => {    const res = await fetch(`/users/${id}`);    if (!res.ok) {      throw new Error(`HTTP ${res.status}`);    }    return res.json();  },  { maxRetries: 3 },);const user = await fetchUser("123");
07semola/i18n

Typed translations

Locale dictionaries with nested keys and typed placeholders. Switch language and interpolate without stringly APIs.

Docs →
messages.ts
import { I18n } from "semola/i18n";const i18n = new I18n({  defaultLocale: "en",  locales: {    en: {      welcome: "Welcome, {name:string}!",    },    es: {      welcome: "Bienvenido, {name:string}!",    },  },});i18n.setLocale("es");i18n.translate("welcome", { name: "Ada" });
08semola/logging

Prefixed loggers

One logger, console and file providers, optional formatters. Enough structure without a logging framework.

Docs →
log.ts
import { Logger, ConsoleProvider } from "semola/logging";const log = new Logger("api", [new ConsoleProvider()]);log.info("server started");log.warning("slow query");log.error("request failed");
09semola/orm

Typed tables and queries

Define columns once. findFirst, inserts, and relations infer from the table definition.

Docs →
users.ts
import {  createOrm,  defineTable,  string,  uuid,} from "semola/orm";const users = defineTable("users", {  id: uuid("id").primaryKey().notNull(),  email: string("email").unique().notNull(),});const db = createOrm({  adapter: "sqlite",  url: ":memory:",  tables: { users },});const user = await db.users.findFirst({  where: { email: "hi@semola.dev" },});
10semola/policy

Authorization as rules

Allow and forbid actions with composable conditions over your domain types. Forbid always wins.

Docs →
posts.ts
import { Policy, eq } from "semola/policy";type Post = {  id: number;  authorId: number;  status: "draft" | "published";};const posts = new Policy<Post>();posts.allow({  action: "read",  conditions: { status: eq("published") },});posts.allow({  action: ["update", "delete"],  conditions: { authorId: eq(user.id) },});const canEdit = posts.can("update", post);
11semola/prompts

Terminal prompts

Ask for text, passwords, confirms, numbers, and selects. Built for real TTYs, mockable in tests.

Docs →
setup.ts
import { input, confirm, select } from "semola/prompts";const name = await input({  message: "Project name",  required: true,});const proceed = await confirm({  message: "Create the project?",  defaultValue: true,});const runtime = await select({  message: "Runtime",  options: [    { value: "bun", label: "Bun" },    { value: "node", label: "Node" },  ],});
12semola/pubsub

Typed pub/sub

Publish and subscribe to JSON messages on a Redis channel. Publisher and subscriber clients stay explicit.

Docs →
events.ts
import { PubSub } from "semola/pubsub";type UserEvent = {  userId: string;  action: "login" | "logout";};const events = new PubSub<UserEvent>({  subscriber: redisSubscriber,  publisher: redisPublisher,  channel: "user-events",});await events.subscribe(async (message) => {  console.log(message.userId, message.action);});await events.publish({  userId: "123",  action: "login",});
13semola/queue

Jobs with retries built in

Redis-backed queues with concurrency, timeouts, and exponential backoff. Enqueue work without reinventing the worker loop.

Docs →
jobs.ts
import { Queue } from "semola/queue";const emails = new Queue({  name: "emails",  redis: redisClient,  concurrency: 4,  retries: 3,  handler: async (data) => {    await sendEmail(data.to, data.subject);  },});await emails.enqueue({  to: "user@example.com",  subject: "Welcome",});
14semola/workflow

Durable steps

Named steps persist outputs. Resume after a process death and skip work that already completed.

Docs →
order.ts
import { defineWorkflow } from "semola/workflow";const fulfillOrder = defineWorkflow<{ orderId: string }>({  name: "fulfill-order",  redis: redisClient,  handler: async ({ input, step }) => {    const payment = await step("charge", async () => {      return charge(input.orderId);    });    await step("ship", async () => {      await ship(payment.orderId);    });  },});await fulfillOrder.start({ orderId: "ord_123" });

Ready when you are

Install the package, open the docs, ship the first typed route.

Getting started