Featured resource
2026 Tech Forecast
2026 Tech Forecast

1,500+ tech insiders, business leaders, and Pluralsight Authors share their predictions on what’s shifting fastest and how to stay ahead.

Download the forecast
  • Lab
    • Libraries: If you want this lab, consider one of these libraries.
    • Core Tech
Labs

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 platform
Lab Info
Level
Intermediate
Last updated
Nov 04, 2026
Duration
50m

Contact sales

By clicking submit, you agree to our Privacy Policy and Terms of Use, and consent to receive marketing emails from Pluralsight.
Table of Contents
  1. Challenge

    Step 1: Meet the Globomantics order service and its decorator layer

    Globomantics runs an internal order-processing service. OrderService calculates totals and applies discounts, PaymentGateway charges a flaky downstream provider, and OrderController exposes 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 under src/decorators/ and src/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.ts and src/services/order-service.ts (step 2), src/decorators/retry.ts, src/decorators/metrics.ts, src/services/payment-gateway.ts and src/services/order-service.ts again (step 3), src/decorators/controller.ts and src/decorators/inject.ts (step 4), and src/experimental/tsconfig.json, src/experimental/decorators.ts and src/experimental/router.ts (step 5). src/registry.ts, src/router.ts and 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 a context object 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 with target.call(this, ...args), because a plain target(...args) loses the receiver and breaks any method that uses this. 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 on context.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 call context.addInitializer to 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 the accessor keyword 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's design:paramtypes type information. Step 5 therefore switches the src/experimental/ folder to experimental decorators and reflect-metadata, so a decorator can read each parameter's type at runtime and know that limit should be a number while status stays a string. ### Check your work as you go

    Each 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=dot
    

    Check your types:

    npm run typecheck
    

    Expect 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 clean typecheck returns to the prompt with no errors listed.

  2. Challenge

    Step 2: Build typed method decorators for observability

    OrderService.calculateTotal calls this.applyDiscount, so any wrapper around these methods has to keep the receiver. src/decorators/logging.ts already has withLogging, a plain higher-order function. It logs correctly, but it calls fn(...args) with no receiver, so a method that uses this breaks as soon as it is wrapped. A standard method decorator fixes that: it receives the original method (target) and a context object 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 for this, 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.ts has the same plain withTiming wrapper 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 value OrderService reports must never change. Both decorators are finished but nothing uses them yet. OrderService.calculateTotal calls this.applyDiscount, which makes it a good proof that the receiver survives: if either decorator dropped this, calculateTotal would 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 an async method Timed would measure only the time to create the promise. Handling both is out of scope here. Timed uses performance.now() instead of Date.now() because it is a monotonic clock meant for measuring durations.

  3. Challenge

    Step 3: Configure retry and metrics decorator factories

    PaymentGateway.charge fails 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, so Retry(3) means up to three calls. src/decorators/retry.ts already 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 Retry repeats 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.ts has the same replace-and-call-through shape you already used for Logged and Timed. Decorators apply bottom-up: the one closest to the method wraps it first, and each decorator above it wraps the result. That ordering matters on charge. If Metrics wraps the outside of Retry, it sees one call no matter how many attempts happen inside. If Metrics sits directly above the method, below Retry, every attempt Retry makes passes back through Metrics on its way to the original charge. Right now charge has 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. Metrics works on any method, not only async ones. OrderService.calculateTotal already carries @Timed from 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.

  4. Challenge

    Step 4: Wire Express controllers with class decorators and injected dependencies

    OrderController is already decorated with @Controller, @Get, @Post and @Inject, but nothing yet turns those decorators into an actual router: the bodies of the decorators in src/decorators/controller.ts and src/decorators/inject.ts are empty, so they have no effect. Standard decorators can store data on context.metadata, an object shared by every decorator on the same class, which the class exposes afterwards as Class[Symbol.metadata]. This needs TypeScript 5.2 or later (the lab uses 5.9), and Node 22 does not define Symbol.metadata, so the starter includes a small polyfill, src/polyfills.ts, that controller.ts imports. You do not edit it. Controller is the decorator that records which prefix a controller owns, and it also registers the class so buildRouter knows to construct it. Method decorators run before the class decorator, so the prefix is not set yet when Get and Post run. The route decorators read it later, when an instance is constructed, by which point Controller has run. With the class registered, its individual routes still need to be recorded. Get(path) and Post(path) share one route helper 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 as this. context.addInitializer is built for that: the function you pass runs every time the class is constructed. buildRouter in src/router.ts already constructs each registered controller, so no route is wired by hand. The handler you write sends res.json(target.call(this, req, res)) straight away, which does not await a returned promise or catch a thrown error. That is fine for this demo; a production router would await the result and pass errors to Express's error handler. Routing solved how OrderController's methods get called. Dependency wiring solves what those methods can call on. OrderController.orderService is declared as an accessor field with @Inject('orderService'), but it is just undefined until the decorator does something about it. An accessor decorator can return an init function that supplies the field's starting value, so the controller never has to construct or look up its own service.

  5. 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:paramtypes type information. Both exist only in experimental decorators, which also use reflect-metadata instead of context.metadata. A class can use only one kind, so the src/experimental/ folder has its own tsconfig.json and its own controller, OrderSearchController, served at /search/orders. Its find method already declares @Query('status') and @Query('limit') on its two parameters, but the decorators in src/experimental/decorators.ts are empty. experimentalDecorators is 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 emit design:paramtypes, so the lab's vite.legacy.ts plugin compiles everything in src/experimental/ with SWC, for both the dev server and the tests. You do not edit it. Controller and Get need 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 no context. Instead of context.metadata, store the prefix and routes with Reflect.defineMetadata and read them back with Reflect.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 where Reflect.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 that limit is a Number. Recording metadata is only half the job: something has to read it back when a request arrives. buildExperimentalRouter in src/experimental/router.ts constructs each experimental controller and registers its routes, but it still calls every handler with no arguments, so find ignores the query string. Finishing it means pulling each bound parameter out of req.query and converting it according to the type Query recorded. A parameter that is missing must stay undefined, not become NaN or an empty string. This lab handles only missing values. A repeated parameter such as ?limit=1&limit=2 arrives as an array, and text such as ?limit=abc becomes NaN; validating those and answering with a 400 is out of scope here, but production code should do it.

  6. 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 dev
    

    It 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 for OrderController and OrderSearchController. 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 through design: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 typecheck
    

    Both finish with no failures and no type errors, so strict mode 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, inside Logged, replace return result; with return 42;, then run the compiler again:

    npm run typecheck
    

    The compiler reports an error on that line, and bash runTest.sh "task 2.1" fails on it before it checks any behavior. Put return 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

Pluralsight’s AI authoring technology is designed to accelerate the creation of hands-on, technical learning experiences. Serving as a first-pass content generator, it produces structured lab drafts aligned to learning objectives defined by Pluralsight’s Curriculum team. Each lab is then enhanced by our Content team, who configure the environments, refine instructions, and conduct rigorous technical and quality reviews. The result is a collaboration between artificial intelligence and human expertise, where AI supports scale and efficiency, and Pluralsight experts ensure accuracy, relevance, and instructional quality, helping learners build practical skills with confidence.

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.

Get started with Pluralsight