Getting Started ​
Ready to use Actions in your project? This guide will walk you through installation, basic usage, and common patterns to help you build composable action pipelines quickly.
Installation ​
Requirements ​
TypeScript: Version 5.3.0 or higher is required. We recommend using the latest stable version for the best experience and type safety.
Configuration: Make sure you have "strict": true (or at least "strictNullChecks": true) enabled in your tsconfig.json:
{
"compilerOptions": {
"strict": true
}
}Install the Package ​
You can install @uni-ts/action using your preferred package manager:
$ npm add @uni-ts/action$ pnpm add @uni-ts/action$ yarn add @uni-ts/action$ bun add @uni-ts/actionIf you want to handle errors using Result types instead of throwing exceptions, you can also install @uni-ts/result:
$ npm add @uni-ts/action @uni-ts/result$ pnpm add @uni-ts/action @uni-ts/result$ yarn add @uni-ts/action @uni-ts/result$ bun add @uni-ts/action @uni-ts/resultYour First Action ​
As you saw in the Introduction, an Action is a composable pipeline that combines middleware functions with a final action. Let's see this on a simple example.
import { createAction, next } from '@uni-ts/action';
// Create a simple greeting action
const greetUser = createAction<{ name: string; isNew?: boolean }>()
.with(({ input }) => {
// Validation middleware
if (!input.name.trim()) {
throw new Error('Name cannot be empty');
}
return next();
})
.with(({ input }) => {
// Context enrichment middleware
const greeting = input.isNew ? 'Hello, ' : 'Welcome back,';
return next({ greeting });
})
.do(({ input, ctx }) => {
// Final action
return `${ctx.greeting} ${input.name}!`;
});
// Execute the action
const result = greetUser({ name: 'Alice', isNew: false });
console.log(result); // "Welcome back, Alice!"Building Action Pipelines ​
Actions are built using a fluent API pattern with three key components.
1. Action Builder ​
Start with createAction() and optionally specify the input type:
import { createAction } from '@uni-ts/action';
// Action with no input
const simpleAction = createAction();
// Action with typed input
const typedAction = createAction<{ userId: string; data: unknown }>();2. Middleware Chain ​
Use .with() to add middleware functions. Each middleware can:
- Continue the pipeline by calling
next()(empty, or with context data to pass further) - Short-circuit the pipeline by returning a value or throwing an exception
import { createAction, next } from '@uni-ts/action';
const action = createAction<{ token: string; amount: number }>()
.with(({ input }) => {
// Middleware can throw to stop the pipeline
if (!input.token) {
throw new Error('Token is required');
}
return next(); // Continue without adding context
})
.with(({ input }) => {
// Middleware can return early to short-circuit
if (input.amount === 0) {
return { success: false, reason: 'Zero amount' };
}
return next({ validAmount: input.amount }); // Continue with context
})
.with(({ ctx }) => {
// Middleware can access previous context
const fee = ctx.validAmount * 0.1;
return next({ fee });
});3. Final Action ​
Use .do() to define what happens when all middleware passes. The main difference between .with() and .do() is that
.with()can return thenext()function to continue the pipeline, while.do()cannot..with()continues the pipeline (returns a builder), while.do()executes the action (returns a function).
import { createAction, next } from '@uni-ts/action';
const processPayment = createAction<{ token: string; amount: number }>()
.with(({ input }) => {
if (!input.token) {
throw new Error('Token required');
}
if (input.amount <= 0) {
return { success: false, error: 'Invalid amount' } as const;
}
return next({ validatedAmount: input.amount });
})
.do(({ input, ctx }) => {
// This only runs if middleware passes
return {
success: true,
transactionId: Math.random().toString(36),
originalAmount: input.amount,
validatedAmount: ctx.validatedAmount,
} as const;
});Context Sharing ​
One of the most powerful features of actions is the ability to share typed context between middleware functions and the final action.
Building Context Step by Step ​
Each middleware can add data to the context using next():
import { createAction, next } from '@uni-ts/action';
const userAction = createAction<{ userId: string }>()
.with(async ({ input }) => {
const user = await fetchUser(input.userId);
return next({ user }); // Add user to context
})
.with(async ({ ctx }) => {
const permissions = await fetchPermissions(ctx.user.id);
return next({ permissions }); // Add permissions to context
})
.with(({ ctx }) => {
const canEdit = ctx.permissions.includes('edit');
return next({ canEdit }); // Add computed value to context
})
.do(({ ctx }) => {
// All context is available and properly typed
return {
user: ctx.user,
permissions: ctx.permissions,
canEdit: ctx.canEdit,
};
});Context Overrides ​
Each new context object is merged with the previous one. You can override the previous context by returning a new object with the same keys.
import { createAction, next } from '@uni-ts/action';
const action = createAction()
.with(() => next({ data: 'data' }))
.with(({ ctx }) => next({ data: ctx.data.toUpperCase() }))
.with(({ ctx }) => next({ data: `${ctx.data} ${ctx.data}` }))
.do(({ ctx }) => ctx.data); // returns "DATA DATA"Async Operations ​
Actions work seamlessly with async functions. When any middleware or action returns a Promise, the entire action becomes async:
import { createAction, next } from '@uni-ts/action';
const asyncAction = createAction<{ url: string }>()
.with(async ({ input }) => {
// Async middleware
const response = await fetch(input.url);
if (!response.ok) {
throw new Error('Failed to fetch');
}
return next({ json: await response.json() });
})
.do(({ ctx }) => processData(ctx.json));
// The action returns a Promise
const result = await asyncAction({ url: 'https://api.example.com/data' });You can mix sync and async middleware freely - the action will automatically become async when needed.
Error Handling ​
Actions provide several approaches to handle errors, from simple exceptions to custom error handling strategies.
Basic Exception Handling ​
By default, any thrown error stops the pipeline and gets wrapped in a ThrownActionError:
import { createAction, next, ThrownActionError } from '@uni-ts/action';
const action = createAction<{ value: number }>()
.with(({ input }) => {
if (input.value < 0) {
throw new Error('Negative values not allowed');
}
return next();
})
.do(({ input }) => input.value * 2);
try {
const result = action({ value: -5 });
} catch (error) {
if (error instanceof ThrownActionError) {
console.log('Action failed:', error.message); // "Negative values not allowed"
console.log('Original error:', error.cause);
}
}Custom Error Handling ​
You can customize how exceptions are handled by providing an onThrow function:
import { createAction } from '@uni-ts/action';
const actionWithCustomError = createAction({
onThrow: (error) => {
console.error('Action failed:', error);
// Return instead of throwing
return { success: false, error: String(error) };
},
})
.with(() => {
throw new Error('Something went wrong');
})
.do(() => ({ success: true, data: 'result' }));
const result = actionWithCustomError();
// Returns: { success: false, error: "Error: Something went wrong" }Safe Actions with Result Types ​
For functional error handling without exceptions, you can utilize safe actions that return Result types instead of throwing.
Installing Result Support ​
Safe actions require the @uni-ts/result package:
npm add @uni-ts/resultCreating Safe Actions ​
Safe actions differ from the regular ones mostly by their return type. While regular actions return values and throw exceptions, safe actions return ok(value) or err(error) values.
import { createSafeAction, next } from '@uni-ts/action/safe';
import { err, isOk, ok } from '@uni-ts/result';
const safeAction = createSafeAction<{ value: number }>()
.with(({ input }) => {
if (input.value < 0) {
// Return error instead of throwing
return err('negative_value');
}
// next() function works as usual
return next();
})
.with(({ input }) => next({ doubled: input.value * 2 }))
.do(({ ctx }) => {
// Return ok() instead of a raw value
return ok(ctx.doubled);
});
// Returns the Result type
const result = safeAction({ value: 10 });
if (isOk(result)) {
console.log('Success:', result.data); // 20
} else {
console.log('Error:', result.error); // 'negative_value'
}Safe vs Regular Actions ​
| Feature | Regular Actions | Safe Actions |
|---|---|---|
| Dependencies | None | @uni-ts/result |
| Import | @uni-ts/action | @uni-ts/action/safe |
| Creator | createAction() | createSafeAction() |
| Error Handling | Throws exceptions | Returns err(error) |
| Middleware Returns | Value, next(), or throw | ok(value), err(error), or next() |
| Action Returns | Value or throw | ok(value) or err(error) |