@forinda/kickjs-core
Inversion-of-Control container, decorators, logger, and error types shared by all KickJS packages.
bootstrap() options
bootstrap(options) (from @forinda/kickjs) is the application entry point. It takes an ApplicationOptions object — every field below. modules is the only required one; everything else has a safe default.
| Option | Type | Default | Description |
|---|---|---|---|
modules | AppModuleEntry[] | required | Feature modules to load (class or defineModule() instance form). |
setup | (registry) => void | — | Imperative/conditional module registration; runs after plugins and the static modules array. |
adapters | AppAdapter[] | [] | Lifecycle hooks (DB, Redis, Swagger, OTel…). Build with defineAdapter(). |
plugins | KickPlugin[] | [] | Bundles of modules + adapters + middleware + DI bindings. Build with definePlugin(). |
contributors | ContributorRegistrations | — | Global context contributors at 'global' precedence. |
runtime | HttpRuntime | expressRuntime() | HTTP engine driver — fastifyRuntime() / h3Runtime() swap the engine. See HTTP Runtimes. |
middlewares | MiddlewareEntry[] | requestId(), express.json() | Global middleware pipeline, in order. Replaces the default stack entirely when set. |
port | number | PORT env, else 3000 | Listen port. |
apiPrefix | string | '/api' | Global route prefix. |
defaultVersion | number | 1 | Routes mount at /{prefix}/v{version}/{path}. |
trustProxy | boolean | number | string | fn | false | Express trust proxy setting. |
jsonLimit | string | number | '1mb' | Max JSON body size — only applied when middlewares is omitted. |
security | { helmet?: boolean } | { helmet: true } | Auto-inject helmet headers unless opted out. |
contextStore | 'auto' | 'manual' | 'auto' | ALS frame for RequestContext.set/get + REQUEST-scoped DI. 'manual' only if you own the frame. |
processHooks | 'auto' | 'errors-only' | 'manual' | 'auto' | Process-level error loggers + SIGINT/SIGTERM → shutdown. Use 'errors-only' when an SDK owns shutdown. |
cluster | boolean | { workers?: number } | false | Fork workers across CPU cores (shared port). |
shutdownTimeout | number | 30000 | Max ms to wait for graceful shutdown (0 disables forced exit). |
logRouteTable | boolean | false | Log a per-controller route summary on startup (info level). |
onNotFound | (req, res, next) => void | built-in 404 | Custom unmatched-route handler. |
onError | (err, req, res, next) => void | built-in | Custom global error handler (built-in formats ZodError / HttpException). |
Deprecated aliases (kept for back-compat; the new name wins when both are set): middleware → middlewares, logRoutesTable → logRouteTable.
import { bootstrap, helmet, cors, requestId } from '@forinda/kickjs'
import { fastifyRuntime } from '@forinda/kickjs/fastify'
await bootstrap({
modules: [HelloModule()],
runtime: fastifyRuntime(),
apiPrefix: '/api',
defaultVersion: 1,
middlewares: [requestId(), helmet(), cors()],
})Configuration that lives in
kick.config.ts(CLI/codegen — pattern, runtime, typegen, db) is separate. See KickConfig.
Container
Singleton IoC container managing dependency registration, resolution, and lifecycle.
class Container {
static getInstance(): Container
static reset(): void
register(token: any, target: Constructor, scope?: Scope): void
registerFactory(token: any, factory: () => any, scope?: Scope): void
registerInstance(token: any, instance: any): void
resolve<T = any>(token: any): T
has(token: any): boolean
bootstrap(): void
}Decorators
Class Decorators
function Injectable(options?: ServiceOptions): ClassDecorator
function Service(options?: ServiceOptions): ClassDecorator
function Component(options?: ServiceOptions): ClassDecorator
function Repository(options?: ServiceOptions): ClassDecorator
function Configuration(): ClassDecorator
function Controller(): ClassDecorator- Injectable / Service / Component / Repository -- Register a class in the container. Semantic aliases with identical behavior.
- Configuration -- Marks a class whose
@Beanmethods produce factory-registered dependencies. - Controller -- Registers a class as an HTTP controller. The route prefix comes from the module's
routes().path, not the decorator.
Method Decorators
function Bean(options?: BeanOptions): MethodDecorator
function PostConstruct(): MethodDecorator
function Transactional(): MethodDecorator- Bean -- Inside a
@Configurationclass, registers the method's return value as a dependency. - PostConstruct -- Called once after the instance is fully constructed and injected.
- Transactional -- Wraps the method in a begin/commit/rollback transaction cycle.
Injection Decorators
function Autowired(token?: any): PropertyOrParameterDecorator
function Inject(token?: any): PropertyOrParameterDecorator
function Value(envKey: string, defaultValue?: any): PropertyDecorator@Autowired and @Inject are interchangeable — same runtime, same types, two names. Each works in two positions:
@Service()
class UserRepo {
// Property position.
@Autowired(DB) private db!: KickDbClient
// Or constructor-parameter position — same decorator works here too.
constructor(@Inject(LOGGER) private logger: Logger) {}
}Calling without an explicit token resolves via the property type / constructor parameter type emitted by emitDecoratorMetadata:
class UserRepo {
@Autowired() private db!: KickDbClient // resolved by type
}- Autowired / Inject -- Property or constructor-parameter dependency injection. Pick the name that reads better; the framework treats them identically.
- Value -- Injects an environment variable, evaluated lazily at access time.
HTTP Route Decorators
function Get(path?: string, validation?: RouteValidation): MethodDecorator
function Post(path?: string, validation?: RouteValidation): MethodDecorator
function Put(path?: string, validation?: RouteValidation): MethodDecorator
function Delete(path?: string, validation?: RouteValidation): MethodDecorator
function Patch(path?: string, validation?: RouteValidation): MethodDecoratorMiddleware and Upload Decorators
function Middleware(...handlers: MiddlewareHandler[]): ClassDecorator & MethodDecorator
function FileUpload(config: FileUploadConfig): MethodDecorator
function Builder(target: any): void- Middleware -- Attach middleware at class or method level.
- FileUpload -- Configure file upload handling for a route handler.
- Builder -- Adds a static
builder()method for fluent object construction.
Types
enum Scope {
SINGLETON = 'singleton',
TRANSIENT = 'transient',
}
type Constructor<T = any> = new (...args: any[]) => T
interface ServiceOptions {
scope?: Scope
}
interface BeanOptions {
scope?: Scope
}
interface RouteDefinition {
method: string
path: string
handlerName: string
validation?: { body?: any; query?: any; params?: any }
}
type MiddlewareHandler = (ctx: any, next: () => void) => void | Promise<void>
interface FileUploadConfig {
mode: 'single' | 'array' | 'none'
fieldName?: string
maxCount?: number
maxSize?: number
/** Short extensions ('jpg'), MIME types ('image/jpeg'), wildcards ('image/*'), or a `(mimetype, filename) => boolean` predicate. */
allowedTypes?: string[] | ((mimetype: string, filename: string) => boolean)
customMimeMap?: Record<string, string>
}
interface TransactionManager<TTx = unknown> {
begin(): Promise<TTx>
commit(tx: TTx): Promise<void>
rollback(tx: TTx): Promise<void>
}
type BuilderOf<T> = {
[K in keyof T as T[K] extends Function ? never : K]-?: (value: T[K]) => BuilderOf<T>
} & { build(): T }
interface Buildable<T> {
builder(): BuilderOf<T>
}AppModule
Interface every feature module must implement.
interface AppModule {
register(container: Container): void
routes(): ModuleRoutes | ModuleRoutes[]
}
type AppModuleClass = new () => AppModule
interface ModuleRoutes {
path: string
router: any
version?: number
controller?: any
}AppAdapter
Lifecycle hooks for plugging in cross-cutting concerns (database, docs, rate limiting). Build adapters with defineAdapter(); the call site mounts the result via bootstrap({ adapters: [...] }). Full narrative at Adapters.
interface AdapterContext {
http: AdapterHttp // engine-agnostic route/mount/static/middleware surface (preferred)
app: any // engine-native app — Express by default; escape hatch
container: Container // DI container
server?: any // http.Server (only available in afterStart)
env: string // NODE_ENV (default: 'development')
isProduction: boolean // true when NODE_ENV === 'production'
}
interface AdapterHttp {
route(method: string, path: string, handler: (ctx: any) => unknown): void
mount(prefix: string, routes: unknown[]): void
serveStatic(prefix: string, dir: string): void
use(mw: unknown, opts?: { path?: string | RegExp | (string | RegExp)[] }): void
}
interface AppAdapter {
name?: string
/** Other plugin/adapter names that must mount before this one. Topo-sorted at boot. */
dependsOn?: readonly KickJsPluginName[]
middleware?(): AdapterMiddleware[]
beforeMount?(ctx: AdapterContext): void
onRouteMount?(controllerClass: any, mountPath: string): void
beforeStart?(ctx: AdapterContext): void
afterStart?(ctx: AdapterContext): void
shutdown?(): void | Promise<void>
contributors?(): ContributorRegistrations
onHealthCheck?(): Promise<{ name: string; status: 'up' | 'down'; message?: string }>
}
type MiddlewarePhase = 'beforeGlobal' | 'afterGlobal' | 'beforeRoutes' | 'afterRoutes'
interface AdapterMiddleware {
handler: any
phase?: MiddlewarePhase
path?: string
}defineAdapter
function defineAdapter<TConfig = Record<string, unknown>, TExtra = unknown>(
options: DefineAdapterOptions<TConfig, TExtra>,
): AdapterFactory<TConfig, TExtra>
interface DefineAdapterOptions<TConfig, TExtra = unknown> {
name: string
version?: string
requires?: { kickjs?: string }
defaults?: Partial<TConfig>
/** Returns the AppAdapter lifecycle object plus any extra public methods (TExtra). */
build(config: TConfig, ctx: BuildContext): Omit<AppAdapter, 'name'> & TExtra
}BuildContext is the same { name, scoped } shape used by definePlugin — see the Plugins reference.
defineAdapter() returns a factory, not an adapter
The function returns an AdapterFactory, not an AppAdapter. bootstrap({ adapters: [...] }) expects the result of calling the factory (MyAdapter(), MyAdapter.scoped('x'), or MyAdapter.async(...)). Passing the factory itself is a type error.
AdapterFactory
The callable returned by defineAdapter(). Same surface as PluginFactory so the mental model is shared.
interface AdapterFactory<TConfig, TExtra = unknown> {
/** Singleton form — name matches the definition. */
(config?: Partial<TConfig>): AppAdapter & TExtra
/** Multi-instance form — name becomes `${defName}:${scopeName}`. */
scoped(scopeName: string, config?: Partial<TConfig>): AppAdapter & TExtra
/** Deferred-config form — inner adapter built inside `beforeStart`. */
async(opts: AdapterAsyncOptions<TConfig>): AppAdapter
/** Read-only access to the original definition. */
readonly definition: Readonly<DefineAdapterOptions<TConfig, TExtra>>
}
interface AdapterAsyncOptions<TConfig> {
inject?: readonly unknown[]
useFactory(...deps: any[]): TConfig | Promise<TConfig>
}.async() skips early adapter hooks
The async form resolves the config inside beforeStart, so the inner adapter's middleware(), contributors(), beforeMount(), and onRouteMount() are not picked up — those phases have already run. Only beforeStart, afterStart, shutdown, and onHealthCheck fire on the lazily-built inner adapter.
The TExtra generic preserves any public methods build() exposes beyond the AppAdapter contract — useful for adapters that ship helpers external callers need to invoke directly (e.g. OtelAdapter.applyRedaction).
Plugins
The highest-level extension primitive — a plugin bundles modules, adapters, middleware, DI bindings, and context contributors into one reusable unit. Build them with definePlugin(); mount them via the plugins?: KickPlugin[] field on bootstrap(). The full narrative lives at Plugins; this section is the type reference.
definePlugin
function definePlugin<TConfig = Record<string, unknown>>(
options: DefinePluginOptions<TConfig>,
): PluginFactory<TConfig>
interface DefinePluginOptions<TConfig> {
name: string
version?: string
requires?: { kickjs?: string }
defaults?: Partial<TConfig>
build(config: TConfig, ctx: BuildContext): Omit<KickPlugin, 'name'>
}
interface BuildContext {
/** Resolved instance name — definition name for the bare call, `${name}:${scope}` for `.scoped()`. */
name: string
/** True when produced by `.scoped()`. */
scoped: boolean
}PluginFactory
The callable returned by definePlugin(). Bare call produces a singleton; .scoped() and .async() cover the multi-instance and deferred-config cases.
interface PluginFactory<TConfig> {
/** Singleton form — `AuthPlugin(config)`. */
(config?: Partial<TConfig>): KickPlugin
/** Multi-instance form — namespaces the resolved name to `${defName}:${scopeName}`. */
scoped(scopeName: string, config?: Partial<TConfig>): KickPlugin
/** Deferred-config form — resolves DI tokens then calls `useFactory` inside `onReady`. */
async(opts: PluginAsyncOptions<TConfig>): KickPlugin
/** Read-only access to the original definition. */
readonly definition: Readonly<DefinePluginOptions<TConfig>>
}
interface PluginAsyncOptions<TConfig> {
inject?: readonly unknown[]
useFactory(...deps: any[]): TConfig | Promise<TConfig>
}.async() skips early hooks
The async form resolves the inner plugin lazily inside onReady, so any modules() / setup() / adapters() / middleware() / contributors() the plugin returns is not registered — those hooks have already run. Use the bare call or .scoped() when the plugin needs to contribute anything other than DI bindings or post-start work.
KickPlugin
The lifecycle object returned by build(). Every hook is optional — emit only what the plugin needs.
interface KickPlugin {
name: string
/** Other plugin names that must mount before this one. Topo-sorted at boot. */
dependsOn?: readonly KickJsPluginName[]
register?(container: Container): void
modules?(): AppModuleEntry[]
setup?(registry: ModuleRegistry): void
adapters?(): AppAdapter[]
middleware?(): any[]
contributors?(): ContributorRegistrations
onReady?(container: Container): void | Promise<void>
shutdown?(): void | Promise<void>
/** DevTools introspection snapshot — runs on poll, keep cheap. */
introspect?(): unknown | Promise<unknown>
/** Plugin-owned tabs rendered inside the DevTools UI. */
devtoolsTabs?(): readonly unknown[]
}KickJsPluginName resolves to string until kick typegen populates KickJsPluginRegistry, at which point it narrows to a string-literal union of every plugin/adapter name in the project — making dependsOn autocomplete-safe.
Mounting plugins
Plugins flow in through bootstrap():
interface ApplicationOptions {
// ... other fields
plugins?: KickPlugin[]
}Plugin hooks fire in this order, before user-supplied modules/adapters/middleware:
register()— DI bindingsmiddleware()— global middlewaremodules()+ user modules — route registrationsetup(registry)— imperative module mountsadapters()+ user adapters — lifecycle hooks- Server starts
onReady()— post-startupshutdown()— on SIGINT/SIGTERM
Cross-plugin ordering respects dependsOn; cycles throw MountCycleError and unknown names throw MissingMountDepError, both at boot.
Context Contributors (#107)
Typed, ordered, declarative way to populate ctx.set('key', value) before a controller handler runs. See the full guide at Context Decorators; this section is a reference.
defineContextDecorator
function defineContextDecorator<
K extends string,
D extends Record<string, unknown> = Record<string, never>,
Ctx extends ExecutionContext = ExecutionContext,
>(spec: ContextDecoratorSpec<K, D, Ctx>): ContextDecorator<K, D, Ctx>
interface ContextDecoratorSpec<K, D, Ctx> {
key: K
deps?: D // typed DI map
dependsOn?: readonly string[] // topo-sorted at boot
optional?: boolean // skip on resolve throw
onError?: (err, ctx) => MaybePromise<Value | undefined> // async-permitted
resolve: (ctx, deps) => MaybePromise<Value>
}The returned function is callable as both a method/class decorator and exposes .registration for non-decorator registration sites (module / adapter / plugin / global hooks).
buildPipeline / runContributors
Pure functions for programmatic use (tests, custom transports). The HTTP router calls these automatically during route mount + per request.
function buildPipeline(
sources: readonly SourcedRegistration[],
options?: { route?: string },
): ContributorPipeline
function runContributors(opts: {
pipeline: ContributorPipeline
ctx: ExecutionContext
container: Container
}): Promise<void>
type ContributorSource = 'method' | 'class' | 'module' | 'adapter' | 'global'Precedence (high → low): method > class > module > adapter > global. Plugin contributors merge at 'adapter'. Same-precedence collisions throw DuplicateContributorError at boot.
Errors
All three are startup-time errors raised by buildPipeline() / route mount — never per request.
class MissingContributorError extends Error {
key
dependent
route?
}
class ContributorCycleError extends Error {
cycle: readonly string[]
route?
}
class DuplicateContributorError extends Error {
key
sources: readonly string[]
}ContextMeta + ExecutionContext
// Augment to type-safely extend ctx.get/set
interface ContextMeta {}
interface ExecutionContext {
get<K extends string>(key: K): MetaValue<K> | undefined
set<K extends string>(key: K, value: MetaValue<K>): void
readonly requestId: string | undefined
}
type MetaValue<K extends string, Fallback = unknown> = K extends keyof ContextMeta
? ContextMeta[K]
: FallbackRequestContext (HTTP) implements ExecutionContext. Future WsContext / QueueContext / CronContext (V2) will too.
Logger
Named logger with component context. Console-based by default (zero deps) and pluggable via Logger.setProvider().
class Logger {
constructor(name?: string)
static for(name: string): Logger
child(name: string): Logger
info(msg: string, ...args: any[]): void
warn(msg: string, ...args: any[]): void
error(msgOrObj: any, msg?: string, ...args: any[]): void
debug(msg: string, ...args: any[]): void
trace(msg: string, ...args: any[]): void
fatal(msg: string, ...args: any[]): void
}
function createLogger(name: string): LoggerLogger.setProvider()
Replace the logging backend for all Logger instances. Every existing logger lazily picks up the new provider on its next log call.
interface LoggerProvider {
info(msg: string, ...args: any[]): void
warn(msg: string, ...args: any[]): void
error(msg: string, ...args: any[]): void
debug(msg: string, ...args: any[]): void
trace?(msg: string, ...args: any[]): void
fatal?(msg: string, ...args: any[]): void
/** Return a child provider scoped to the given component name */
child(bindings: { component: string }): LoggerProvider
}
Logger.setProvider(provider: LoggerProvider): void
Logger.getProvider(): LoggerProvider
Logger.resetProvider(): void- setProvider -- Replaces the active logging backend for all loggers. Clears the internal logger cache so subsequent
Logger.for()calls use the new provider. - getProvider -- Returns the currently active provider (useful for testing).
- resetProvider -- Reverts to the default console-based provider. Intended for test teardown.
Built-in provider:
| Provider | Description |
|---|---|
ConsoleLoggerProvider (default) | Uses console.* methods. Zero deps; accepts an optional prefix string |
Bring your own backend (Pino, Winston, …) by implementing LoggerProvider and passing it to Logger.setProvider() — see the Logging guide.
import { Logger, ConsoleLoggerProvider } from '@forinda/kickjs'
// Switch all loggers to console output
Logger.setProvider(new ConsoleLoggerProvider())
const log = Logger.for('MyService')
log.info('Hello') // Output: [MyService] Hello
// Restore default console backend (e.g. in afterEach)
Logger.resetProvider()CircuitBreaker
Protects your application from cascading failures when downstream services are unhealthy by short-circuiting requests after a configurable failure threshold.
The breaker transitions through three states: closed (normal operation), open (requests rejected), and half_open (limited probe requests allowed to test recovery).
type CircuitBreakerState = 'closed' | 'open' | 'half_open'
interface CircuitBreakerOptions {
/** Number of consecutive failures before the circuit opens */
failureThreshold: number
/** Milliseconds to wait before transitioning from OPEN to HALF_OPEN */
resetTimeout: number
/** Max requests allowed in HALF_OPEN state before deciding (default 1) */
halfOpenMax?: number
}
interface CircuitBreakerStats {
failures: number
successes: number
state: CircuitBreakerState
lastFailure?: Date
}
class CircuitBreaker {
readonly name: string
constructor(name: string, options: CircuitBreakerOptions)
execute<T>(fn: () => Promise<T>): Promise<T>
getState(): CircuitBreakerState
getStats(): CircuitBreakerStats
reset(): void
}- execute -- Run an async function through the breaker. Throws
CircuitOpenErrorwhen the circuit is open. - getState -- Returns the current state, auto-transitioning from
opentohalf_openwhen the reset timeout has elapsed. - getStats -- Returns current failure/success counters and state.
- reset -- Manually force the circuit back to
closedand zero all counters.
CircuitOpenError
Thrown by execute() when the circuit is open or the half-open probe limit has been reached.
class CircuitOpenError extends Error {
readonly breakerName: string
constructor(breakerName: string)
}Usage
import { CircuitBreaker, CircuitOpenError } from '@forinda/kickjs'
const breaker = new CircuitBreaker('payment-api', {
failureThreshold: 5,
resetTimeout: 30_000,
})
try {
const result = await breaker.execute(() => fetch('https://payment.example.com/charge'))
} catch (err) {
if (err instanceof CircuitOpenError) {
// Fail fast — downstream service is unhealthy
}
}Cluster Mode
Run multiple worker processes sharing the same port for multi-core utilization. The primary process forks workers and forwards SIGTERM/SIGINT signals. Dead workers are automatically restarted after a short delay.
interface ClusterOptions {
/** Number of worker processes (default: os.cpus().length) */
workers?: number
}
function isClusterPrimary(): booleanEnable cluster mode through the bootstrap() options:
import { bootstrap } from '@forinda/kickjs'
// Use all available CPU cores
bootstrap({ modules, cluster: true })
// Use exactly 4 workers
bootstrap({ modules, cluster: { workers: 4 } })When cluster is enabled and the current process is the primary:
- The primary forks
workerschild processes (defaults toos.cpus().length). - Each worker calls
bootstrap()independently and shares the port via Node's built-inclustermodule (OS round-robin load balancing). - SIGTERM/SIGINT on the primary is forwarded to all workers.
- Dead workers are restarted after a 1-second delay.
Use isClusterPrimary() to check if the current process is the primary (e.g. for one-time initialization tasks like database migrations).
Health Endpoints
Built-in health check endpoints are mounted at the root path (outside the API prefix) before any middleware runs.
GET /health/live
Liveness probe. Returns 200 with { status: 'ok', uptime } when the server is running. Returns 503 with { status: 'draining', uptime } when the application is shutting down.
GET /health/ready
Readiness probe. Runs onHealthCheck() on every adapter that implements it and aggregates the results. Returns 200 with { status: 'ready', checks } when all adapters report healthy. Returns 503 with { status: 'degraded', checks } when any adapter is down. Returns 503 with { status: 'draining', checks: [] } during shutdown.
// Example adapter with health check
const dbAdapter: AppAdapter = {
name: 'postgres',
async onHealthCheck() {
await pool.query('SELECT 1')
return { name: 'postgres', status: 'up' }
},
}Graceful Shutdown
The Application tracks in-flight requests and provides a graceful shutdown sequence that drains active connections before tearing down adapters.
class Application {
/** Whether the application is currently draining in-flight requests */
get isDraining(): boolean
/** Number of HTTP requests currently being processed */
get inFlightRequests(): number
/** Initiate graceful shutdown */
shutdown(): Promise<void>
}Shutdown sequence
- Stop accepting connections --
server.close()prevents new TCP connections. - Drain in-flight requests -- Waits for all active requests to complete their response (tracked via
finish/closeevents). - Run adapter and plugin shutdowns -- Calls
shutdown()on all registered adapters and plugins concurrently viaPromise.allSettled. - Force exit on timeout -- If requests do not drain within
shutdownTimeout(default 30 seconds), the shutdown proceeds anyway. Set to0to disable the forced timeout.
Safe to call multiple times -- subsequent calls are no-ops.
import { bootstrap } from '@forinda/kickjs'
const app = await bootstrap({
modules,
shutdownTimeout: 15_000, // 15 seconds (default: 30_000)
})
// Trigger shutdown on SIGTERM (already wired by bootstrap, shown for clarity)
process.on('SIGTERM', () => app.shutdown())During draining, the /health/live and /health/ready endpoints return 503 so load balancers can stop routing traffic to the instance.
HttpException
Typed HTTP error with static factories for common status codes.
class HttpException extends Error {
readonly status: number
readonly details?: ValidationError[]
constructor(status: number, message: string, details?: ValidationError[])
static fromZodError(error: any, message?: string): HttpException
static badRequest(message?: string): HttpException
static unauthorized(message?: string): HttpException
static forbidden(message?: string): HttpException
static notFound(message?: string): HttpException
static conflict(message?: string): HttpException
static unprocessable(message?: string, details?: ValidationError[]): HttpException
static tooManyRequests(message?: string): HttpException
static internal(message?: string): HttpException
}
interface ValidationError {
field: string
message: string
code?: string
}