Building Custom Context Decorators in KickJS

typescript dev.to

In Express you reach for req.locale = ... inside a middleware and hope the
downstream handler remembers what type it is. KickJS gives you a better home
for that data: a context decorator. You declare the key once, write a
typed resolve(ctx) function, register it at the scope it belongs to, and
every handler in that scope can call ctx.get('locale') with full type
safety. Anything you'd hang off req — request id, locale, the audit actor,
feature flags, an idempotency key — should live here instead. This article
is the field guide: the two factories, the full options surface, the five
registration scopes and how they fight, how to read values back from
handlers and services, the boot-time errors you should know on sight, and
three patterns worth stealing.

The two factories

KickJS ships two factories, and the difference is whether your resolver
needs to see the HTTP request.

defineHttpContextDecorator is for HTTP routes. The ctx argument it hands
your resolver is a RequestContextctx.req, ctx.headers, ctx.params,
ctx.body, plus the shared get/set/requestId surface. Reach for it
when the value is derived from the wire: parsing an Accept-Language
header, lifting an X-Idempotency-Key, decoding a cookie, anything that
only makes sense in the HTTP transport.

defineContextDecorator is transport-agnostic. The ctx it gives you is
an ExecutionContext with only get, set, and requestId — no req,
no headers. That's deliberate: a contributor built on this factory will
also work the same way inside a CLI command, a queue worker, a cron job,
or whatever non-HTTP transport adapter you bolt on next. Reach for it when
the value is computed from other context entries (dependsOn) or from DI
services (deps), not from the request itself. A LoadFeatureFlags
contributor that takes the resolved actor id from dependsOn and asks a
flag service is a perfect fit — it doesn't care whether the call came in
over HTTP.

The rule of thumb: start with defineContextDecorator and only escalate
to the HTTP variant if your resolver actually touches ctx.req.
Keeping
contributors transport-agnostic where you can means the day you mount the
same module under a queue worker, every contributor still works.

The options surface

Both factories take the same options object. Six fields, each pulling its
weight.

export const LoadLocale = defineHttpContextDecorator({
  key: 'locale',
  deps: [LocaleService] as const,
  dependsOn: ['actor'],
  optional: false,
  onError: (_err, _ctx) => 'en',
  resolve: (ctx, [locales]) => {
    const actor = ctx.get('actor');
    return locales.resolveFor(actor, ctx.headers['accept-language']);
  },
});
Enter fullscreen mode Exit fullscreen mode
  • key is the string handle the rest of the system uses to find this value. It's also the field you'll augment in ContextMeta for type safety. Pick something stable — renaming a key is a breaking change for every handler that reads it.
  • resolve(ctx, deps?) is the work itself. Sync or async, returns the value to be stored under key. Throwing here is a real error unless optional or onError says otherwise.
  • deps is a tuple of DI tokens. Whatever you list shows up as the second argument to resolve, already constructed and scoped to the current request. Mark it as const so the tuple types survive into the argument signature.
  • dependsOn declares which other context keys must be resolved before this one runs. KickJS topologically sorts contributors at boot and guarantees the order. If you read ctx.get('actor') inside resolve, put 'actor' in dependsOn — otherwise you're racing.
  • optional: true swallows resolver errors silently. The key simply ends up undefined. Use it for genuinely optional signals (a missing idempotency header is fine; a missing tenant probably isn't).
  • onError(err, ctx) is the recovery hatch. If resolve throws and onError is defined, its return value gets stored under key. Use it for sane defaults — a failed locale lookup falling back to 'en', a missing feature-flag service falling back to an empty set.

Five places to register a contributor

A decorator definition is inert until you register it somewhere. KickJS
offers five scopes, and they form a strict precedence chain:

Method > Class > Module > Adapter > Global.

  • Method@LoadLocale() placed on a single handler. Highest precedence; a route-specific override that beats every wider scope.
  • Class@LoadLocale() on a controller class. Applies to every handler on that controller. The right scope when a single resource is the only thing that needs the value.
  • Module — return it from AppModule.contributors?(). Every route the module mounts gets it. Best for module-domain values that don't belong to the whole app.
  • Adapter — return it from AppAdapter.contributors?(). Every route in the app gets it. This is where cross-cutting concerns (request id, trace context, the actor lookup) belong.
  • Global — pass it via bootstrap({ contributors: [...] }). App-wide defaults, lowest precedence — anything below can override.

The precedence rule does two different things depending on whether the
collision is at the same level or across levels.

Same level, same key: KickJS throws DuplicateContributorError at
boot. Two adapter-level contributors both producing 'locale' is a bug,
not a configuration question, and the framework refuses to guess. Pick
one.

Cross level, same key: the higher-precedence one silently wins. A
method decorator beats a class decorator beats a module contributor beats
an adapter contributor beats a global one. No warning, no error — that's
the point of overrides. Define LoadLocale adapter-wide as the default,
then attach a LoadLocale({ source: 'query' }) method-level decorator on
the one admin route that wants the locale taken from ?locale= instead
of headers, and the override happens cleanly.

A useful mental model: register where the responsibility actually
lives.
Cross-cutting concerns belong on the adapter. Module-domain
values belong on the module. One-off overrides belong on the method.
Global is for defaults you'll let any of the above replace.

Reading the value back

Inside a handler you have ctx, so it's just ctx.get(key):

@Controller('/orders')
export class OrdersController {
  @Get('/')
  list(@RequestCtx() ctx: RequestContext) {
    const locale = ctx.get('locale'); // typed via ContextMeta
    return this.orders.list(locale);
  }
}
Enter fullscreen mode Exit fullscreen mode

Inside a service — somewhere with no ctx in scope — you reach for the
async-local-storage helpers:

import { getRequestValue, getRequestStore } from '@forinda/kickjs';

export class OrdersService {
  list() {
    const locale = getRequestValue<string>('locale'); // T | undefined
    const { requestId } = getRequestStore();          // throws outside a request
    this.log.info({ requestId, locale }, 'listing orders');
  }
}
Enter fullscreen mode Exit fullscreen mode

getRequestValue is the safe one — returns T | undefined, fine to call
anywhere. getRequestStore throws if there's no active request frame, so
use it only when you're certain you're inside one (a request handler, a
service called from one). The async-local-storage backing means it works
through await boundaries without you threading ctx everywhere.

Errors and recovery

Two boot-time errors are worth recognizing on sight:

  • MissingContributorError — a contributor declares dependsOn: ['actor'] but no contributor in the resolved chain produces 'actor'. Thrown at boot, before any request is served. Fix: register the missing contributor or remove the dependency.
  • ContributorCycleErrorA depends on B, B depends on A. Also thrown at boot. Fix: break the cycle, usually by extracting the shared piece into a third contributor both depend on.

At request time, optional and onError are your two recovery levers.
optional: true says "if this fails, just leave the key undefined and
keep going." onError says "if this fails, here's the value to use
instead." Reach for optional when downstream code already handles
undefined; reach for onError when there's a meaningful default.

Three patterns worth stealing

Per-route override. Define an adapter-level LoadLocale that reads
Accept-Language. On the one admin route that takes ?locale= from the
query string, attach a method-level @LoadLocale({ source: 'query' }).
The method scope beats the adapter scope silently — no if branches in
the resolver, no flags, just precedence doing the work.

Plugin-shipping. When you publish a contributor as part of a library,
export both the @DecoratorName form and the underlying .registration
object. Consumers who want it on every route call bootstrap({
contributors: [LoadFeatureFlags.registration] })
. Consumers who want it
on a single controller use @LoadFeatureFlags(). Same definition, both
ergonomics.

export const LoadFeatureFlags = defineContextDecorator({
  key: 'flags',
  deps: [FlagsService] as const,
  resolve: (_ctx, [flags]) => flags.snapshot(),
});
// Consumers reach for either LoadFeatureFlags() or LoadFeatureFlags.registration.
Enter fullscreen mode Exit fullscreen mode

Factory wrapper for parameterized decorators. When the same
contributor needs different config per call site, wrap the factory in a
function:

export const LoadRequestStartedAt = (clock: () => number = Date.now) =>
  defineContextDecorator({
    key: 'requestStartedAt',
    resolve: () => clock(),
  });
Enter fullscreen mode Exit fullscreen mode

Now LoadRequestStartedAt() uses the real clock, LoadRequestStartedAt(()
=> 0)
is trivially mockable in tests, and the type signature stays clean.

A note on type safety

ctx.get('locale') returns unknown until you augment the type. Drop
this once per key, in a .d.ts file or near the contributor definition:

declare module '@forinda/kickjs' {
  interface ContextMeta {
    locale: string;
    requestStartedAt: number;
    flags: ReadonlySet<string>;
  }
}
Enter fullscreen mode Exit fullscreen mode

After that every ctx.get is fully typed. There's a separate article on
the augmentation mechanics — the short version is: yes, do it, every key,
every time.

Testing

Unit-test the resolver in isolation. Mock its deps, hand it a stub ctx
with the dependsOn keys pre-seeded via set, assert the return value.
That's it — no app boot, no HTTP, no DI container.

Integration-test by booting the real adapter through createTestApp and
hitting it with supertest. The contributor chain runs end-to-end and you
verify what handlers actually see.

For service-layer reads — code that calls getRequestValue or
getRequestStore — wrap the assertion in a requestStore.run frame:

requestStore.run(
  { requestId: 'test', instances: new Map(), values: { locale: 'fr' } },
  () => expect(service.preferredLocale()).toBe('fr'),
);
Enter fullscreen mode Exit fullscreen mode

That's the same async-local-storage backing the framework uses at
runtime, so the production code path runs unchanged.

References

Source: dev.to

arrow_back Back to Tutorials