DEV Community

Cover image for Type-Safe Augmentation Patterns in KickJS — ContextMeta, AuthUser, PolicyRegistry
Orinda Felix Ochieng
Orinda Felix Ochieng

Posted on

Type-Safe Augmentation Patterns in KickJS — ContextMeta, AuthUser, PolicyRegistry

Decorator decorations are unsound until you augment. @Roles('owner', 'admin') looks like it is type-checked — it is not, until you tell TypeScript what AuthUser['roles'] actually is. ctx.get('tenant') looks like it returns a tenant — it does not, until you tell ContextMeta that 'tenant' is a key. @Can('delete', 'invoice') looks like a permission check — but until PolicyRegistry knows about 'invoice', both arguments are just string. KickJS v5 ships these three interfaces deliberately empty so each adopter can fill them in, and the framework's helper types pivot on whether you have. This article walks the three augmentation surfaces, the catalogue-only defineAugmentation() helper, and the file-organisation rules — generically, with minimal examples.

The general pattern

Every augmentation in KickJS uses the same TypeScript primitive — module declaration merging:

declare module '@forinda/kickjs' {
  interface ContextMeta {
    /* your keys */
  }
}

declare module '@forinda/kickjs-auth' {
  interface AuthUser {
    /* your shape */
  }
  interface PolicyRegistry {
    /* your resources */
  }
}
Enter fullscreen mode Exit fullscreen mode

You are reaching into the framework's exported interface, adding members from your project, and the compiler stitches the result into a single shape at every consumption site. The framework ships each interface empty (or with a permissive index signature) so unaugmented projects continue to compile against the loose fallback; once you augment, every helper type that pivots on keyof ContextMeta or AuthUser['roles'][number] automatically tightens.

A few mechanical rules — get these wrong and the augmentation silently does nothing:

  • The file must be a module. Add export {} at the bottom if you do not have any other top-level imports/exports. A plain script with declare module blocks is treated as ambient and the merge does not always land where you expect.
  • The module specifier inside declare module '...' must match the published package name verbatim — '@forinda/kickjs' and '@forinda/kickjs-auth' are different modules, so ContextMeta lives in the first, AuthUser and PolicyRegistry in the second.
  • tsconfig.json must include the .d.ts file. If you put augmentations in src/types/ and your include is ["src/**/*"], you are fine. If you carved out a narrower glob, double-check.

The convention this article recommends is one file per interface, all under a dedicated src/types/ folder. The reasoning is in "Where to put augmentation files" below.

ContextMeta — typed ctx.get()

The framework declares ContextMeta as an empty marker interface, paired with a MetaValue<K, Fallback> helper that resolves to ContextMeta[K] if the key is registered, and to Fallback (default unknown) otherwise. ctx.get<K extends string>(key) then dispatches through MetaValue. With nothing augmented, every ctx.get('anything') resolves to unknown | undefined — strict, but it does not break the call site.

Once you augment, the same call narrows. A typical src/types/context-meta.d.ts:

import type { AuthenticatedUser } from '@/auth/types'
import type { Tenant } from '@/tenancy/types'

declare module '@forinda/kickjs' {
  interface ContextMeta {
    user: AuthenticatedUser
    tenant: Tenant
  }
}

export {}
Enter fullscreen mode Exit fullscreen mode

Now ctx.get('tenant') is Tenant | undefined and ctx.get('user') is AuthenticatedUser | undefined. Two keys, two narrowings, zero runtime cost.

The contributors that populate those keys are a separate mechanism — typically defineHttpContextDecorator({ key: 'tenant', resolve: ... }) registered in your adapter layer. The relationship is worth saying out loud because beginners trip on it: the augmentation declares types, the contributor declares values. They are tied by the literal key string. If you augment ContextMeta with tenant: Tenant but never register a contributor that resolves 'tenant' for the route, the framework throws a MissingContributorError at boot when it builds the per-route pipeline. That is the system catching a typo (or a forgotten registration) before the first request hits production.

A common sub-pattern is marking certain contributors optional: true so they can no-op on routes where the value is genuinely absent (health checks, public webhooks). The | undefined already in ctx.get's return type accommodates this without callers needing extra ceremony.

AuthUser['roles'] — typed @Roles

@Roles is the most common decorator that benefits from augmentation, and the framework's machinery for it is the cleanest demonstration of why module merging beats subclassing or generics.

The framework's Role helper does roughly this: it looks at AuthUser['roles'], and if the type is the literal any (which it will be when AuthUser is unaugmented and falls back to its index signature), it widens Role to string; otherwise, when roles has been narrowed to a tuple or array of literal strings, it pulls the element type out and uses that. @Roles<R extends Role>(...roles: R[]) then keys off the result.

The reason that any check is load-bearing: any matches every conditional branch, so without an explicit pivot the helper would collapse to Role = any and @Roles(...) would silently accept anything — numbers, objects, undefined. The pivot keeps the unaugmented case loose-but-sane (Role = string) and lets the augmented case tighten into a proper literal union.

A minimal augmentation:

type AppRole = 'owner' | 'admin' | 'member' | 'viewer'

declare module '@forinda/kickjs-auth' {
  interface AuthUser {
    roles: AppRole[]
  }
}

export {}
Enter fullscreen mode Exit fullscreen mode

After this, @Roles('owner', 'admin') typechecks and @Roles('admni') is a red squiggle at the decoration site, not a 403 in production.

There is a second layer worth noting — defense in depth. The augmentation only narrows what the TypeScript compiler can see. A forged JWT with "roles": ["god-mode"] could still arrive at runtime, and if your mapPayload returned that payload as-is, downstream @Roles checks would compare against 'god-mode' and reject the request, but the user object would carry an off-union string. The fix is to filter explicitly in mapPayload:

const allowed = ['owner', 'admin', 'member', 'viewer'] as const
const roles = (Array.isArray(payload.roles) ? payload.roles : []).filter(
  (r): r is (typeof allowed)[number] => allowed.includes(r),
)
Enter fullscreen mode Exit fullscreen mode

So bogus roles are scrubbed before they ever land in the AuthUser shape. The compile-time augmentation gives you authoring safety; the mapPayload filter gives you runtime safety. The two together mean every code path that reads user.roles@Roles, your authorization service, your service-layer ABAC checks — sees the same closed union.

PolicyRegistry — typed @Can

@Can('action', 'resource') follows the same pattern, with two indices instead of one. The framework declares PolicyRegistry empty, then derives PolicyResource and PolicyAction<R> from it. The trick is that when the registry has no keys at all, both helpers fall back to string so unaugmented projects keep compiling; the moment you add keys, the resource argument narrows to your declared keys and the action argument narrows per-resource:

declare module '@forinda/kickjs-auth' {
  interface PolicyRegistry {
    invoice: 'create' | 'read' | 'update' | 'delete' | 'void'
    user: 'invite' | 'suspend'
  }
}

export {}
Enter fullscreen mode Exit fullscreen mode

After this:

@Can('void', 'invoice')        // ✓
@Can('delete', 'invoice')      // ✓
@Can('typo', 'invoice')        // ✗ 'typo' not in invoice's actions
@Can('invite', 'invoice')      // ✗ 'invite' is a 'user' action
@Can('delete', 'unknown')      // ✗ 'unknown' is not a registered resource
Enter fullscreen mode Exit fullscreen mode

The augmentation file becomes the single source of truth: every time a new resource gains an action, you update the registry once and every existing @Can call site is re-checked.

The defineAugmentation() catch

There is one extra primitive that confuses everyone the first time they see it. KickJS exports a defineAugmentation('InterfaceName', { description, example }) helper, and the temptation is to assume calling it is the augmentation. It is not.

defineAugmentation is purely a discovery aid — it is a no-op at runtime and a no-op at the type level. All it does is add an entry to a typegen catalogue so the kick typegen command can render a single .kickjs/types/augmentations.d.ts file listing every augmentable interface in the project plus a worked example. A new contributor can grep that one file and see the surface area at a glance.

It does nothing to the type system. If you call only defineAugmentation and skip the declare module block, your ctx.get('tenant') is still unknown. Conversely, if you have the declare module block and skip defineAugmentation, everything works perfectly — you just lose the catalogue entry. The two are independent. Treat defineAugmentation as documentation that your linter can find.

Where to put augmentation files

A few file-organisation rules that save grief:

  • One block per interface, one file per block. TypeScript will happily merge multiple declare module blocks for the same interface across the project, but tools (IDE go-to-definition, code search, refactor tooling) work better when each augmentable interface has exactly one home. A typical layout: src/types/context-meta.d.ts, src/types/auth-augment.d.ts, src/types/policy-augment.d.ts. When a new resource appears, you know exactly which file to open.
  • Always end with export {}. Without it the file is a script, and ambient script semantics differ from module semantics in ways that bite you.
  • Import the value types as type-only. import type { Tenant } from '@/tenancy/types' keeps the augmentation file out of the runtime graph — .d.ts files have no runtime, but a stray value-import pulls the source file into the IDE's "go to" results and clutters refactor scope.
  • Confirm tsconfig.include covers `src/types/.** A .d.ts` outside the project's compilation graph does nothing, and TypeScript will not warn you.

Why this matters

The whole augmentation surface is small — three interfaces, three files — but the leverage is enormous. Every decorator, every ctx.get(key), every policy call site becomes compile-checked. When a role or resource changes in the source-of-truth file, every consumer is forced to acknowledge it. Drift between "what the auth provider hands me" and "what the decorator thinks is allowed" stops being a runtime mystery and becomes a compile error in the same PR that introduced it.

The pattern also composes. New surfaces — a typed feature-flag registry, a typed event bus, a typed job queue — fit the same shape: a framework-side empty interface, an adopter-side declare module block, a helper type pivoting on keyof or extends never, and an optional catalogue-only defineAugmentation call. That is what "type-safe by construction" looks like in v5.

References

Top comments (0)