{"id":488,"date":"2026-07-21T13:00:54","date_gmt":"2026-07-21T13:00:54","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/typescript-decorators-what-changed-when-i-dropped-the-flag\/"},"modified":"2026-07-21T13:00:54","modified_gmt":"2026-07-21T13:00:54","slug":"typescript-decorators-what-changed-when-i-dropped-the-flag","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/typescript-decorators-what-changed-when-i-dropped-the-flag\/","title":{"rendered":"TypeScript Decorators: What Changed When I Dropped the Flag"},"content":{"rendered":"<p>For about six years, every tsconfig I touched had <code>\"experimentalDecorators\": true<\/code> in it. I didn&rsquo;t think about it. It was load-bearing furniture. NestJS wanted it, TypeORM wanted it, some class-validator setup wanted it, and the flag went in with the same reflex as <code>\"strict\": true<\/code>.<\/p>\n<p>Then I started a new project last year, forgot to add the flag, wrote a decorator anyway, and it compiled. That was a confusing five minutes. It turns out TypeScript 5.0 shipped the standard, TC39-aligned decorators, and they work without any flag at all. They&rsquo;re also a different feature wearing the same syntax, which is a great way to lose an afternoon if nobody tells you.<\/p>\n<p>Short version: if you&rsquo;re starting fresh, use the standard decorators and delete the flag. If you&rsquo;re on NestJS or TypeORM, keep the flag, because the old and new versions can&rsquo;t share a file. The interesting part is what changed in between.<\/p>\n<h2 id=\"two-features-one-syntax\">Two features, one syntax<\/h2>\n<p>The legacy implementation was TypeScript&rsquo;s own design, built on a much older version of the TC39 proposal that then changed substantially. The standard implementation follows the <a href=\"https:\/\/github.com\/tc39\/proposal-decorators\" rel=\"nofollow noopener\" target=\"_blank\">current TC39 decorators proposal<\/a>, which reached stage 3 and is what browsers and other runtimes will eventually implement natively.<\/p>\n<p>That matters more than it sounds. Legacy decorators were a TypeScript-only dialect. Standard decorators are JavaScript that TypeScript happens to type check. Anything you write against the new API has a future outside the TypeScript compiler.<\/p>\n<p>The flag switches which implementation you get. There&rsquo;s no mixed mode. If <code>experimentalDecorators<\/code> is on, you get the old behaviour everywhere, and none of what follows applies to your codebase.<\/p>\n<h2 id=\"what-a-decorator-actually-receives-now\">What a decorator actually receives now<\/h2>\n<p>Here&rsquo;s a method decorator the old way. Three positional arguments, a mutable property descriptor, and a lot of trust that you know what <code>target<\/code> is:<\/p>\n<pre><code class=\"language-ts\">function log(target: any, key: string, descriptor: PropertyDescriptor) {\n  const original = descriptor.value;\n  descriptor.value = function (...args: any[]) {\n    console.log(`calling ${key}`);\n    return original.apply(this, args);\n  };\n  return descriptor;\n}\n<\/code><\/pre>\n<p>The signature never told you much. <code>target<\/code> is the prototype for instance methods and the constructor for statics, which you had to remember rather than read. Mutating <code>descriptor.value<\/code> in place and also returning it is belt and braces. And <code>key<\/code> is a <code>string<\/code> in the type signature even though it can be a symbol.<\/p>\n<p>Here&rsquo;s the same thing with standard decorators:<\/p>\n<pre><code class=\"language-ts\">function log&lt;T extends (...args: any[]) =&gt; any&gt;(\n  original: T,\n  context: ClassMethodDecoratorContext\n): T {\n  return function (this: unknown, ...args: any[]) {\n    console.log(`calling ${String(context.name)}`);\n    return original.call(this, ...args);\n  } as T;\n}\n\nclass Billing {\n  @log\n  charge(amount: number) {\n    return amount * 100;\n  }\n}\n<\/code><\/pre>\n<p>Two arguments. The first is the thing being decorated, which for a method is the function itself rather than a descriptor wrapping it. The second is a context object that tells you what you&rsquo;re looking at: <code>kind<\/code> (<code>\"method\"<\/code>, <code>\"field\"<\/code>, <code>\"getter\"<\/code>, <code>\"setter\"<\/code>, <code>\"class\"<\/code>, <code>\"accessor\"<\/code>), <code>name<\/code>, <code>static<\/code>, <code>private<\/code>, plus a couple of functions I&rsquo;ll get to.<\/p>\n<p>You return a replacement or you return nothing. No descriptor mutation. The narrower context types are the part I appreciate most day to day, because <code>ClassMethodDecoratorContext<\/code> and <code>ClassFieldDecoratorContext<\/code> are different types and the compiler will tell you when you&rsquo;ve applied a decorator to the wrong kind of member. Under the legacy implementation that was a runtime surprise.<\/p>\n<h2 id=\"the-initializer-hook-that-replaced-my-constructor-hacks\">The initializer hook that replaced my constructor hacks<\/h2>\n<p><code>context.addInitializer<\/code> is the piece I didn&rsquo;t expect to use as much as I do. It registers a callback that runs during construction, with <code>this<\/code> bound to the instance.<\/p>\n<p>The classic use is auto-binding methods so you can pass them around as callbacks without losing <code>this<\/code>:<\/p>\n<pre><code class=\"language-ts\">function bound(\n  original: Function,\n  context: ClassMethodDecoratorContext\n) {\n  context.addInitializer(function (this: any) {\n    this[context.name] = original.bind(this);\n  });\n}\n\nclass Toolbar {\n  label = &quot;Save&quot;;\n\n  @bound\n  onClick() {\n    console.log(this.label);\n  }\n}\n\nconst t = new Toolbar();\nconst handler = t.onClick;\nhandler(); \/\/ logs &quot;Save&quot; instead of exploding\n<\/code><\/pre>\n<p>Before this existed I was doing the same job with arrow function class properties, which works but allocates a fresh closure per instance per method and doesn&rsquo;t live on the prototype. It also can&rsquo;t be overridden by a subclass in the way people expect. The decorator version is more honest about what it&rsquo;s doing.<\/p>\n<p>Registration is the other good use. Rather than maintaining an array of route handlers by hand, an initializer can push into a registry as each instance builds itself. That&rsquo;s most of what a decorator-based framework does under the hood, and it&rsquo;s now about eight lines instead of a metadata reflection dance.<\/p>\n<h2 id=\"metadata-and-the-thing-that-actually-broke\">Metadata, and the thing that actually broke<\/h2>\n<p>The old ecosystem leans hard on <code>reflect-metadata<\/code>. Every DI container I&rsquo;ve used stores constructor parameter types in a metadata side table, reads them back at resolution time, and hands you an instance. That depends on <code>emitDecoratorMetadata<\/code>, which only works with the legacy flag on.<\/p>\n<p>Standard decorators have their own metadata story via <code>context.metadata<\/code> and <code>Symbol.metadata<\/code>, added in TypeScript 5.2 and described in the <a href=\"https:\/\/www.typescriptlang.org\/docs\/handbook\/release-notes\/typescript-5-0.html\" rel=\"nofollow noopener\" target=\"_blank\">TypeScript 5.0 release notes<\/a> and the releases that followed. It&rsquo;s a plain object shared across decorators on the same class, and you read it off the constructor afterwards:<\/p>\n<pre><code class=\"language-ts\">function tag(value: string) {\n  return function (_target: unknown, context: ClassMethodDecoratorContext) {\n    (context.metadata as any)[context.name] = value;\n  };\n}\n<\/code><\/pre>\n<p>What it does not give you is parameter types. There is no standard equivalent of <code>emitDecoratorMetadata<\/code> emitting design-time type information, because that was always a TypeScript-specific emit and the standard proposal is deliberately runtime-only. So constructor-injection containers that infer dependencies from types can&rsquo;t be ported straight across. They need explicit tokens instead.<\/p>\n<p>This is why NestJS and TypeORM still ask for the legacy flag, and it isn&rsquo;t laziness on their part. It&rsquo;s a genuine capability gap. I spent a while assuming I&rsquo;d missed a migration guide before working out there wasn&rsquo;t one to miss.<\/p>\n<h2 id=\"where-i-dont-use-decorators-at-all\">Where I don&rsquo;t use decorators at all<\/h2>\n<p>Validation. I used to decorate every DTO field and I&rsquo;ve stopped. Schema-first libraries give me a parsed, typed value and a runtime error I can serialise, without the class ever needing to exist. Decorators tie validation to a class shape, and half the time the thing I&rsquo;m validating arrived as JSON and wants to stay a plain object.<\/p>\n<p>Anything where a plain higher order function does the job. <code>withRetry(fn)<\/code> reads more clearly than <code>@retry<\/code> on a method, it&rsquo;s easier to test in isolation, and nobody has to check the tsconfig to understand it. I reach for a decorator when the behaviour genuinely belongs to the class member and needs to know its own name, and otherwise I don&rsquo;t.<\/p>\n<p>Public library APIs, mostly. Asking consumers to configure a compiler feature so your package works is a real cost, and it&rsquo;s a cost that gets worse for anyone bundling for the browser.<\/p>\n<p>The general pattern here matches what I found writing about <a href=\"https:\/\/abrarqasim.com\/blog\/typescript-satisfies-the-type-casts-i-finally-stopped-writing\" rel=\"noopener\">the type casts I stopped writing with satisfies<\/a>: the newer feature is usually narrower and more boring than the thing it replaced, and that&rsquo;s the point.<\/p>\n<h2 id=\"migrating-without-breaking-everything\">Migrating without breaking everything<\/h2>\n<p>The migration path is per project, not per file, which is the awkward bit. You can&rsquo;t flip one module over and leave the rest. So the realistic options are: start new projects on standard decorators, and leave existing framework-heavy projects on the flag until the framework moves.<\/p>\n<p>For the code I actually control, the port was mechanical. Method decorators lose the descriptor and gain a context object. Field decorators change the most, because the old ones ran against the prototype and the new ones return an initializer function that transforms the field&rsquo;s starting value:<\/p>\n<pre><code class=\"language-ts\">function upper(_target: undefined, _context: ClassFieldDecoratorContext) {\n  return function (initial: string) {\n    return initial.toUpperCase();\n  };\n}\n\nclass User {\n  @upper name = &quot;qasim&quot;;\n}\n<\/code><\/pre>\n<p>That return-a-function shape confused me for longer than I&rsquo;d like to admit. The decorator itself runs once at class definition time; the function it returns runs per instance, per field. Once you see it as two phases it stops being weird.<\/p>\n<p>Class decorators are the closest to unchanged. You get the constructor and a context, and returning a new constructor still replaces the class. The <a href=\"https:\/\/www.typescriptlang.org\/docs\/handbook\/release-notes\/typescript-5-2.html\" rel=\"nofollow noopener\" target=\"_blank\">TypeScript 5.2 release notes<\/a> cover the metadata addition specifically, which is worth reading if you maintain anything that stores per-member configuration.<\/p>\n<p>One practical warning: your bundler and your test runner both need to agree with your compiler about which decorator flavour is in play. I had a build that passed and a test suite that didn&rsquo;t, and the answer was an esbuild target setting nobody had touched in two years.<\/p>\n<h2 id=\"what-to-try-this-week\">What to try this week<\/h2>\n<p>Open your tsconfig and check whether <code>experimentalDecorators<\/code> is actually needed. If nothing in your dependency tree requires it, delete the line, run the build, and see what falls over. On a small codebase that&rsquo;s a ten minute experiment with a clear answer.<\/p>\n<p>If it stays, write down why. A one-line comment saying which package needs it saves the next person the archaeology I did.<\/p>\n<p>And if you&rsquo;ve been auto-binding methods with arrow class properties, try the <code>bound<\/code> decorator above on one class. It&rsquo;s a small change with a visible payoff, and it&rsquo;s a low-risk way to get a feel for the new API before you rewrite anything that matters. I do a fair amount of this kind of incremental TypeScript modernisation on client codebases, and there&rsquo;s more of that in <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">my work<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I carried experimentalDecorators in tsconfig for six years. Here is what the standard TypeScript decorators actually changed, and why NestJS still needs the flag.<\/p>\n","protected":false},"author":2,"featured_media":487,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I carried experimentalDecorators in tsconfig for six years. Here is what the standard TypeScript decorators actually changed, and why NestJS still needs the flag.","rank_math_focus_keyword":"typescript decorators","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[45],"tags":[254,44,557,201,63],"class_list":["post-488","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-programming","tag-decorators","tag-javascript","tag-tc39","tag-tooling","tag-typescript"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/488","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=488"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/488\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/487"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=488"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=488"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=488"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}