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
}
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
}
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)
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
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
}
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[]
}
If you're not familiar with ts-jackson from the original post
Top comments (0)