- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
Turn TypeScript Classes into Decorator-Driven Applications
Globomantics runs an internal order-processing service. Orders are calculated and discounted by an OrderService, payments are charged through a flaky PaymentGateway that needs retries and metrics, and an Express OrderController exposes routes for creating and listing orders. The team has decided every cross-cutting concern (logging, timing, retries, metrics, routing, dependency wiring, and query-parameter binding) should be expressed as decorators instead of scattered boilerplate, and you are the developer building that decorator layer.
Lab Info
Table of Contents
-
Challenge
Step 1: Meet the Globomantics order service and its decorator layer
Globomantics runs an internal order-processing service.
OrderServicecalculates totals and applies discounts,PaymentGatewaycharges a flaky downstream provider, andOrderControllerexposes Express routes for creating and listing orders. Right now the cross-cutting concerns (logging, timing, retries, metrics, routing, dependency wiring, and query binding) either don't exist yet or live in stub files undersrc/decorators/andsrc/experimental/. Across Steps 2 to 5 you will fill in those stubs so each concern is expressed as a decorator instead of scattered boilerplate inside the services and controllers. The files you will touch, in the order you will touch them:src/decorators/logging.ts,src/decorators/timing.tsandsrc/services/order-service.ts(step 2),src/decorators/retry.ts,src/decorators/metrics.ts,src/services/payment-gateway.tsandsrc/services/order-service.tsagain (step 3),src/decorators/controller.tsandsrc/decorators/inject.ts(step 4), andsrc/experimental/tsconfig.json,src/experimental/decorators.tsandsrc/experimental/router.ts(step 5).src/registry.ts,src/router.tsand the controllers already work and stay untouched. They show you what a finished decorator gets applied to and where shared instances live. A method decorator is a function TypeScript calls once, at class-definition time, with the original method and acontextobject that holds the method's name. Whatever it returns replaces the method, so returning a function that calls the original and does something around that call is how logging, timing, and retries get added without editing the method itself. The replacement must call the original withtarget.call(this, ...args), because a plaintarget(...args)loses the receiver and breaks any method that usesthis. A decorator factory is a function that returns a decorator, which is how@Retry(3)can configure how many attempts belong to this particular method instead of every method sharing one hardcoded number. A class decorator runs once with the class and can store data oncontext.metadata, an object shared by every decorator on that class, which is how@Controller('/orders')tells the route decorators which prefix a controller owns. A method decorator can also callcontext.addInitializerto run code each time an instance is created, which is how routes get registered on an Express router without any manual wiring. An accessor decorator runs on a field declared with theaccessorkeyword and can supply its initial value, which is how@Inject('orderService')fills a dependency from the registry. Parameter decorators are different: standard decorators do not have them, and they cannot emit the compiler'sdesign:paramtypestype information. Step 5 therefore switches thesrc/experimental/folder to experimental decorators andreflect-metadata, so a decorator can read each parameter's type at runtime and know thatlimitshould be a number whilestatusstays a string. ### Check your work as you goEach task is graded automatically when you finish it, and you can also run the checks yourself in the Terminal at any time. If you started the dev server in Step 6, press Ctrl+C first to free the terminal.
Run one task's test, with the task name in quotes:
bash runTest.sh "task 2.1"Run the whole suite:
npx --no-install vitest run --reporter=dotCheck your types:
npm run typecheckExpect failures at first. The starter code is unfinished on purpose, so each task's check fails until you complete that task. If you run the whole suite before you start, you see many failures. That is the normal starting point and does not mean your setup is wrong.
As you complete each task, its check changes from failing to passing. When all fifteen tasks are done, the whole suite passes.
When a task passes, the summary shows
Tests 1 passed. When it fails, the message names the task and tells you what to change. Each task's test runs the TypeScript compiler before it checks behavior, so a type error fails the task with the compiler's own message. After npm prints the script header, a cleantypecheckreturns to the prompt with no errors listed. -
Challenge
Step 2: Build typed method decorators for observability
OrderService.calculateTotalcallsthis.applyDiscount, so any wrapper around these methods has to keep the receiver.src/decorators/logging.tsalready haswithLogging, a plain higher-order function. It logs correctly, but it callsfn(...args)with no receiver, so a method that usesthisbreaks as soon as it is wrapped. A standard method decorator fixes that: it receives the original method (target) and acontextobject with the method's name, and returns the replacement. Inside the replacement,target.call(this, ...args)runs the original on the right instance. The decorator signatures are already typed with generics forthis, the arguments and the return value, so what you write is the body. When you finish a task, use the commands in Check your work as you go in Step 1 to test it. Logging says a method ran. Timing says how long it took.src/decorators/timing.tshas the same plainwithTimingwrapper and the same typed decorator signature. The difference is what the replacement measures instead of what it announces, and because it still has to call the original and return its result, the valueOrderServicereports must never change. Both decorators are finished but nothing uses them yet.OrderService.calculateTotalcallsthis.applyDiscount, which makes it a good proof that the receiver survives: if either decorator droppedthis,calculateTotalwould fail the moment it ran.These two decorators are deliberately simple. They have no
try/finally, so a method that throws logs no result and records no timing, and they do not wait for promises, so on anasyncmethodTimedwould measure only the time to create the promise. Handling both is out of scope here.Timedusesperformance.now()instead ofDate.now()because it is a monotonic clock meant for measuring durations. -
Challenge
Step 3: Configure retry and metrics decorator factories
PaymentGateway.chargefails on its first attempt and succeeds on its second, so it needs retries, but a fixed retry count baked into the decorator would be wrong for every other method. A decorator factory fixes that:Retry(attempts)is a function that returns the actual decorator, so each call site chooses its own attempt count. The count is the total number of calls, soRetry(3)means up to three calls.src/decorators/retry.tsalready has the factory shape and the typed signature. What is missing is the loop that retries a failing call instead of giving up on the first rejection.Good practice: this
Retryrepeats a failed call immediately. Retrying a payment charge without a delay between attempts or an idempotency key can charge a customer twice, so a production gateway would add backoff and send an idempotency key with each charge. Retries are only useful if you can see them happen.Metrics(name)is a second decorator factory, meant to record one entry per call to the method it wraps, labeled with the name you pass in.src/decorators/metrics.tshas the same replace-and-call-through shape you already used forLoggedandTimed. Decorators apply bottom-up: the one closest to the method wraps it first, and each decorator above it wraps the result. That ordering matters oncharge. IfMetricswraps the outside ofRetry, it sees one call no matter how many attempts happen inside. IfMetricssits directly above the method, belowRetry, every attemptRetrymakes passes back throughMetricson its way to the originalcharge. Right nowchargehas no decorators at all, so nothing retries it and nothing counts it. As noted in Task 3.1, a real payment retry also needs backoff and an idempotency key.Metricsworks on any method, not only async ones.OrderService.calculateTotalalready carries@Timedfrom the previous step. Adding a metric means stacking one more decorator on a method that is already decorated, so the order you write them in decides which one wraps which. -
Challenge
Step 4: Wire Express controllers with class decorators and injected dependencies
OrderControlleris already decorated with@Controller,@Get,@Postand@Inject, but nothing yet turns those decorators into an actual router: the bodies of the decorators insrc/decorators/controller.tsandsrc/decorators/inject.tsare empty, so they have no effect. Standard decorators can store data oncontext.metadata, an object shared by every decorator on the same class, which the class exposes afterwards asClass[Symbol.metadata]. This needs TypeScript 5.2 or later (the lab uses 5.9), and Node 22 does not defineSymbol.metadata, so the starter includes a small polyfill,src/polyfills.ts, thatcontroller.tsimports. You do not edit it.Controlleris the decorator that records which prefix a controller owns, and it also registers the class sobuildRouterknows to construct it. Method decorators run before the class decorator, so the prefix is not set yet whenGetandPostrun. The route decorators read it later, when an instance is constructed, by which pointControllerhas run. With the class registered, its individual routes still need to be recorded.Get(path)andPost(path)share oneroutehelper in the same file. The first part of that helper is about storing enough information (the HTTP method, the path, and which method on the class handles it) for something else to list every route later. Recording routes is only bookkeeping. To get real routes, something has to register them on an Express router, and it has to happen for each controller instance because the handler needs that instance asthis.context.addInitializeris built for that: the function you pass runs every time the class is constructed.buildRouterinsrc/router.tsalready constructs each registered controller, so no route is wired by hand. The handler you write sendsres.json(target.call(this, req, res))straight away, which does notawaita returned promise or catch a thrown error. That is fine for this demo; a production router wouldawaitthe result and pass errors to Express's error handler. Routing solved howOrderController's methods get called. Dependency wiring solves what those methods can call on.OrderController.orderServiceis declared as anaccessorfield with@Inject('orderService'), but it is justundefineduntil the decorator does something about it. An accessor decorator can return aninitfunction that supplies the field's starting value, so the controller never has to construct or look up its own service. -
Challenge
Step 5: Switch to reflected experimental decorators for query parameters
Standard decorators have no parameter decorators, and they cannot emit the compiler's
design:paramtypestype information. Both exist only in experimental decorators, which also usereflect-metadatainstead ofcontext.metadata. A class can use only one kind, so thesrc/experimental/folder has its owntsconfig.jsonand its own controller,OrderSearchController, served at/search/orders. Itsfindmethod already declares@Query('status')and@Query('limit')on its two parameters, but the decorators insrc/experimental/decorators.tsare empty.experimentalDecoratorsis already on for that folder. The compiler still has to be told to emit type metadata. esbuild, the default compiler in Vite and Vitest, cannot emitdesign:paramtypes, so the lab'svite.legacy.tsplugin compiles everything insrc/experimental/with SWC, for both the dev server and the tests. You do not edit it.ControllerandGetneed to work again for experimental decorators. They receive different arguments than standard decorators: the class itself, or the prototype and the method name, and there is nocontext. Instead ofcontext.metadata, store the prefix and routes withReflect.defineMetadataand read them back withReflect.getMetadata, so the router can find them later. Now the part standard decorators could not do. A parameter decorator runs with the class prototype, the method name and the parameter's position, but not its type. That is whereReflect.getMetadata('design:paramtypes', ...)comes in: it returns the compile-time type of every parameter on a method, so a decorator that only ever sees one index can still know thatlimitis aNumber. Recording metadata is only half the job: something has to read it back when a request arrives.buildExperimentalRouterinsrc/experimental/router.tsconstructs each experimental controller and registers its routes, but it still calls every handler with no arguments, sofindignores the query string. Finishing it means pulling each bound parameter out ofreq.queryand converting it according to the typeQueryrecorded. A parameter that is missing must stayundefined, not becomeNaNor an empty string. This lab handles only missing values. A repeated parameter such as?limit=1&limit=2arrives as an array, and text such as?limit=abcbecomesNaN; validating those and answering with a 400 is out of scope here, but production code should do it. -
Challenge
Step 6: Run and check the application
Every task is done, and each one was graded the moment you completed it. What you have not done yet is watch the whole application run at once, which is the only place the pieces you wrote separately become a single working program.
Start the application
Open the Terminal tab and start the dev server:
npm run devIt serves the application on port 5173. Switch to the Web Browser tab to use it: the page is running the decorators you implemented in the previous steps, so what you see is your own code rather than a demonstration of it.
The page has five sections. Select Calculate total for demo order and the order total appears, with new entries added under Cross-cutting logs. Select Charge $49.99 and the gateway fails its first attempt, retries, and reports
charged:49.99, with Attempts so far showing 2 and two new metrics entries. Registered Controllers lists the routes your decorators recorded forOrderControllerandOrderSearchController. Under Call the API, the buttons send real requests to the Express router your decorators built, including/search/orders?status=paid&limit=1, which binds its query parameters throughdesign:paramtypes.The server reloads on save, so you can change a file and see the result without restarting anything. When you are done looking around, press Ctrl+C in the Terminal to stop the server and free the terminal for the checks in the next section. ### Run every check once more
With all fifteen tasks done, the checks from Step 1 should all pass. Run the whole suite and the compiler together in the Terminal:
npx --no-install vitest run --reporter=dot && npm run typecheckBoth finish with no failures and no type errors, so
strictmode found no problems in either the standard or the experimental part of the project. Two things are worth trying before you finish. First, break something on purpose and watch the matching check fail, then put it back. Knowing what a failure looks like is worth as much as knowing what a pass looks like, and it costs you nothing here.Second, ask the compiler to catch a type mistake. In
src/decorators/logging.ts, insideLogged, replacereturn result;withreturn 42;, then run the compiler again:npm run typecheckThe compiler reports an error on that line, and
bash runTest.sh "task 2.1"fails on it before it checks any behavior. Putreturn result;back when you are done. Congratulations on completing the Globomantics decorator layer.You built the cross-cutting layer behind the order service: logging and timing method decorators that keep the receiver and return value intact, retry and metrics factories stacked in a deliberate order, controller and route decorators that register routes on a real Express router through instance initializers, an accessor decorator that injects dependencies from a registry, and, after switching to experimental decorators, a query decorator that reads reflected parameter types. The page you ran in the browser exercises that whole layer.
You also ran TypeScript's strict compiler over all of it and watched it catch a type mistake before any test did. Knowing what a decorator receives, when it runs, what it must return, and which kind of decorator a feature needs are skills that carry over to any decorator-based TypeScript framework.
About the author
Real skill practice before real-world application
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Learn by doing
Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.
Follow your guide
All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.
Turn time into mastery
On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.