Decorators

Stack decorators on action class methods to add cross-cutting behavior.

import {
  OnError,
  OnSuccess,
  Debounce,
  Throttle,
  Authorized,
  Retry,
  Queue,
  Log,
  Validate,
} from 'comwit'

Built-in decorators

DecoratorPurpose
@OnError(fn)Error handler. Receives the error. Re-throw to propagate.
@OnSuccess(fn)Runs after successful completion.
@Debounce(ms)Debounces the method call.
@Throttle(ms)Throttles the method call.
@Authorized({ when, onDeny })Auth guard. when: () => boolean | Promise<boolean>
@Retry({ times, delay? })Retries on error up to times. Optional delay between retries.
@Queue()Queues concurrent calls, executing one at a time.
@Log(level?)Logs method execution with timing. Levels: 'info' | 'debug' | 'warn'.
@Validate(validators)Validates arguments before execution. Throws ValidationError on failure.

Example

import { action, OnError, OnSuccess, Debounce } from 'comwit'

export const postActions = action<Pick<PostActions, 'create' | 'search'>, AppContext>(
  ({ state, context }) => {
    class Actions {
      private model = state(post)

      @OnSuccess(() => context.router.push('/posts'))
      @OnError((e) => toast.error(e instanceof Error ? e.message : 'Failed'))
      async create(title: string) {
        const created = await api.post.create({ title })
        this.model.posts.data.push(created)
      }

      @Debounce(300)
      async search(keyword: string) {
        await this.model.posts.query(keyword)
      }
    }
    return new Actions()
  }
)

Custom Decorators with intercept()

Build reusable decorators with full lifecycle control.

import { intercept } from 'comwit'

intercept() supports two modes:

  • Immediate mode — hooks applied at decoration time. Use when you don't need runtime context.
  • Lazy mode — factory receives ActionContext with state and context, resolved when the action binds to a provider.

Immediate mode

const MyLog = intercept({
  onBefore: (...args) => console.log('called with', args),
  onSuccess: (result) => console.log('returned', result),
  onError: (err) => console.error('failed', err),
})

Available hooks: onBefore, onSuccess, onError, onSettled, intercept.

Lazy mode

Use when the decorator needs access to model state or provider context.

const LoginRequired = intercept<AppContext>(({ state, context }) => {
  const u = state(user)
  return {
    intercept: (execute, args) => {
      if (!u.me) {
        context.router.push('/login')
        return
      }
      return execute(...args)
    },
  }
})

Use it like any other decorator:

@LoginRequired
@OnSuccess(() => context.router.push('/posts'))
async create(title: string) {
  const created = await api.post.create({ title })
  this.model.posts.data.push(created)
}

Decorators with arguments

Create parameterized decorators by wrapping intercept() in a function:

function MinLength(field: string, min: number) {
  return intercept({
    intercept: (execute, args) => {
      if (args[0]?.length < min) throw new Error(`${field} must be at least ${min} chars`)
      return execute(...args)
    },
  })
}

@Validate

Validates arguments before execution. Validators map keys to argument positions.

import { Validate, ValidationError } from 'comwit'

class TodoActions {
  @Validate({
    title: (v) => (typeof v === 'string' && v.length > 0) || 'Title required',
    priority: (v) => [1, 2, 3].includes(v) || 'Priority must be 1-3',
  })
  async createTodo(title: string, priority: number) {
    // ...
  }
}

Each validator returns true for pass, or a string error message. On failure, throws ValidationError with an .errors record containing all failed validations.