DEV Community

Cover image for Your decorator library silently breaks on Vite. Here's why (and a fix) — ts-jackson 2.0
Ilias Gazdaliev
Ilias Gazdaliev

Posted on

Your decorator library silently breaks on Vite. Here's why (and a fix) — ts-jackson 2.0

You have a class that maps an API response. It works perfectly in your old webpack + ts-loader project:

@Serializable()
class Event {
  @JsonProperty()
  startsAt: Date

  @JsonProperty()
  attendees: number
}
Enter fullscreen mode Exit fullscreen mode

You move to Vite. It compiles. It runs. No warnings. And then, somewhere far from this file, production throws event.startsAt.getTime is not a function — because startsAt is quietly a plain string now, and attendees might be one too.

Nothing crashed at the mapping site. That's what makes this failure mode nasty. Here's what's actually going on.

Why: the two-flag magic trick

Every classic decorator-metadata library — class-transformer, TypeORM, typescript-json-serializer, ts-jackson 1.x — relies on two tsconfig flags:

{
  "experimentalDecorators": true,
  "emitDecoratorMetadata": true
}
Enter fullscreen mode Exit fullscreen mode

The first enables the legacy decorator syntax. The second is the magic one: it makes the TypeScript compiler look at your type annotation (startsAt: Date) and secretly emit a runtime value next to the decorator:

// what tsc actually generates (simplified)
Reflect.metadata('design:type', Date)
Enter fullscreen mode Exit fullscreen mode

That's how @JsonProperty() with zero configuration knows it should build a Date. The type annotation — normally erased at compile time — gets smuggled into the runtime.

Here's the catch: emitting design:type requires actually resolving types. Is startsAt's annotation a class? An interface? An import alias? Answering that means type-checking the program.

Now recall why esbuild is fast: it transpiles file-by-file and never type-checks. It just strips annotations. So esbuild doesn't implement emitDecoratorMetadata — not as an oversight, but because implementing it would require becoming the thing it was built to replace. And Vite transforms your TypeScript with esbuild.

The result on a stock Vite project:

Reflect.getMetadata('design:type', target, 'startsAt') // undefined
Enter fullscreen mode Exit fullscreen mode

The decorator still runs. Metadata is just… missing. Your mapping library falls back to "no type known", passes the raw JSON value through, and you get a string where a Date should be. No error at the site of the lie — only downstream, at runtime, in whatever code first trusts the type.

(SWC, for the record, can emit decorator metadata, but it's opt-in config — stock setups frequently miss it too.)

The fix: decorators that don't need a compiler accomplice

Since 5.0 — with decorator metadata following in 5.2, TypeScript's default decorator mode — no flags at all — implements the TC39 standard decorators proposal, together with the decorator-metadata proposal (Symbol.metadata). Standard decorators are pure runtime semantics: the decorator receives a context object, stores whatever metadata it wants, and no compiler magic is involved anywhere.

Which means they work under esbuild. And Vite. And every bundler that can handle modern JavaScript — because there's nothing to implement beyond the syntax itself.

There's one honest trade-off: the standard has no equivalent of design:type, by design — JavaScript has no types at runtime, and TC39 doesn't standardize compiler tricks. So type inference from annotations is gone; where conversion matters, you declare the type explicitly.

ts-jackson 2.0: both worlds, auto-detected

I just shipped ts-jackson 2.0, and the headline feature is exactly this: it supports both decorator modes, and detects at runtime which one invoked it. Same imports, same API.

Legacy mode (tsc, flags on) — full type inference, exactly like v1:

@Serializable()
class Event {
  @JsonProperty()
  startsAt: Date            // inferred from the annotation
}
Enter fullscreen mode Exit fullscreen mode

Standard mode (Vite, esbuild, no flags) — explicit types:

@Serializable()
class Event {
  @JsonProperty({ type: Date })
  startsAt: Date

  @JsonProperty({ elementType: Image })  // elementType implies an array
  images: Image[]
}
Enter fullscreen mode Exit fullscreen mode

If you're not familiar with ts-jackson from the original post

Top comments (0)