Standard decorators: props & cons

UQL is a dependency-free TypeScript ORM for PostgreSQL, MySQL, MariaDB, SQLite and MongoDB.
UQL 0.23 moved its decorators to the TC39 standard spec. experimentalDecorators and emitDecoratorMetadata are gone, reflect-metadata is gone, and one thing became possible that was not possible before.
It also deleted three decorators, removed parameter injection, and locked NestJS out of the decorator API entirely. Migration posts usually stop at the wins. This is the whole ledger.
What it looks like
Section titled “What it looks like”import { Entity, Field, Id, ManyToOne } from 'uql-orm';
import 'reflect-metadata';
@Entity()class User { @Id() id?: number;
@Field() name?: string;
@ManyToOne() company?: Relation<Company>;}@Entity()class User { @Id({ type: Number }) id?: number;
@Field({ type: String }) name?: string;
@ManyToOne({ entity: () => Company }) company?: Company;}More typing. type on every field, entity on every relation, because nothing reflects any more. That looks like a pure loss until you notice what the compiler can now do with it.
The type you declare is checked against the property
Section titled “The type you declare is checked against the property”This is the part worth the migration.
Under the legacy spec, a property decorator has this shape:
type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;There is no type parameter carrying the property’s type. The decorator is handed a key and nothing else, so the options you pass it are just data. Write this and it compiled fine:
@Field({ type: String })age?: number;You got a TEXT column for a number, and you found out when the data looked wrong. emitDecoratorMetadata did not save you, because an explicit type skipped inference entirely:
if (opts.type) { opts = { ...opts, typeInferred: false };} else { opts = { ...opts, type: inferType(entity, key), typeInferred: true };}Reflection was the fallback, not the check. The moment you stated a type, the one thing that knew the real type stopped looking.
The standard spec hands a field decorator a ClassFieldDecoratorContext<This, Value>, which is generic in the field’s value type. That single difference is the whole story, because now a decorator can constrain what it may be attached to:
/** A member decorator that also constrains the property it may be applied to. */type MemberDecorator<V> = (value: undefined, context: ClassFieldDecoratorContext<unknown, V>) => void;
/** A declared `type` wins; otherwise the column is the referenced primary key's own type. */type DeclaredValue<O> = O extends { readonly type: infer T extends FieldType } ? TsTypeOf<T> : O extends { readonly references: EntityGetter<infer E> } ? IdValue<E> : never;
export function Field<O extends FieldOptions<DeclaredValue<O>> & ({ type: FieldType } | { references: EntityGetter })>( opts: O,): MemberDecorator<DeclaredValue<O> | undefined>;@Field({ type: String }) returns a decorator that only applies to string | undefined. Put it on a number and it does not compile. The mandatory type stopped being redundant typing and became a claim the compiler checks.
Once one option can name the value a property holds, the others have to agree with it:
import { OneToMany } from 'uql-orm';
@ManyToOne({ entity: () => Company })supplier?: Vendor; // error: entity and property disagree
@OneToMany({ entity: () => Item })items?: Item; // error: a to-many cardinality needs an array
@Field({ type: 'int' })createdAt?: Date; // error: a Date field is not an integer column
@Field({ references: () => User })authorId?: number; // error: User's key is a uuid, so this column is a string
@Id({ type: 'uuid', onInsert: () => 42 })id?: string; // error: a uuid column is not stamped with a numberNone of these were catchable before. They are not new bugs the migration introduced, they are old bugs it made visible. A type-test suite pins every one of them and fails the build if any stops erroring, and all but the foreign key survive being reached through the imperative defineEntity too.
The last two arrived later, in 0.24.3. Same mechanism, applied to the two other things that decide what a column holds: the key a foreign key points at, and the generator that stamps a value into it.
What reflection was costing
Section titled “What reflection was costing”reflect-metadata was 264 KB, carried for one call: Reflect.getMetadata('design:type', ...). It also required every consumer to import it once, globally, before any entity loaded, and to remember to keep two compiler flags on.
It bought less than it looked like. Reflected types could not survive a circular import, which is why Relation<T> existed at all: an alias whose only job was to break a cycle that reflection itself created. It is deleted now.
Dropping it is part of why the package installs 1 MB with no dependencies.
What we gave up
Section titled “What we gave up”The honest column.
@InjectQuerier() is gone. The standard spec has no parameter decorators, and the TC39 proposal for them is still Stage 1. A @Transactional() method now reads its querier from AsyncLocalStorage:
import { Transactional, currentQuerier } from 'uql-orm';
class UserService { @Transactional() async register(data: Partial<User>) { await currentQuerier().insertOne(User, data); }}@Log() and @Serialized() are gone. A standard-spec decorator cannot preserve a generic method’s signature, so both would have quietly widened the types of anything they wrapped. The useful half of @Log(), error enrichment, was moved into the querier itself.
NestJS projects cannot use UQL’s decorators at all. Nest injects constructor parameters with a parameter decorator, so a Nest project keeps experimentalDecorators: true, and one tsconfig.json cannot mix specs. This is not a temporary gap that closes when a proposal advances a stage. Those projects use defineEntity instead, which carries the same checks, bar the foreign key one.
target: 'esnext' is now forbidden. It is the one target where TypeScript emits decorator syntax untransformed, which Node and every browser reject with a SyntaxError. Every dated target downlevels it correctly.
declare fields stop working. The spec has nothing to decorate on a declare member, so narrowing an inherited relation needs a real field with an initializer.
Oxc, Vite 8’s default transformer, implements no decorators at all. It preserves @Entity() verbatim at every target, so a Vite 8 project needs esbuild, SWC or Babel through a plugin. esbuild, SWC, Babel with version: '2023-11', Bun and tsc all handle the spec.
Node 24 is the new minimum, shipped in the same release.
The two things that nearly broke it
Section titled “The two things that nearly broke it”Neither is in the spec documents, and both cost real time.
Symbol.metadata does not exist yet. No runtime we support defines it, checked on Node 24 and Bun 1.3. TypeScript’s decorator emit reads it to decide whether to build the metadata object at all, so without a polyfill every context.metadata is undefined and field registration is silently dropped rather than failing. UQL defines it with Symbol.for, not Symbol(), so a duplicated copy of the module under HMR or dual-loading lands on the same symbol, and so it agrees with the key esbuild and SWC fall back to.
tsc and SWC disagree about inheritance. tsc chains a subclass’s context.metadata to its parent’s. SWC does not, in any decorator version. Anything built on that prototype chain works under one compiler and quietly loses inherited fields under the other. UQL resolves inheritance by walking the class prototype chain instead, and there is a test that constructs the unchained shape SWC emits to prove it.
The codemod
Section titled “The codemod”uql-codemod does most of the mechanical work, reading types from the real type checker rather than guessing from the parse tree:
npx uql-codemod --project=tsconfig.json --dry-runnpx uql-codemod --project=tsconfig.jsonIt rewrites @Field/@Id types, relation entity getters, Relation<T> to T, @InjectQuerier() to currentQuerier(), and strips both flags from tsconfig.json while preserving your comments and formatting. It refuses to touch what it cannot be sure about: target: esnext, values inherited through extends, @Log() and @Serialized(), options objects it cannot read, and branded string ids.
tsc is the rest of the migration, and that is the point. The annotations the codemod inserts are checked against the properties they describe, so anything it got wrong is a compile error rather than a silently wrong column.
Was it worth it
Section titled “Was it worth it”Yes, and not because of the compiler flags.
Removing two tsconfig settings and a 264 KB polyfill is worth something, but it is housekeeping. The reason to do this was that reflection put the type in two places and let them drift, and no amount of care fixes a design where the compiler cannot see the mistake. Now there is one declaration and the compiler checks it against the property.
The bill was real: three decorators, parameter injection, and a framework’s worth of users pushed onto a different API. If you are on NestJS, this release cost you something and handed back a smaller install. That is a worse trade than everyone else got, and not one I can improve until TC39 moves.
Get started
Section titled “Get started”npm i uql-ormnpx uql-codemod --project=tsconfig.json --dry-run- Upgrade guide
- Entities: decorators and the imperative API
- Zero dependencies: what we deleted to fit on the edge
- How UQL compares
- GitHub
If the codemod leaves something behind that it could have handled, open an issue with the entity that tripped it.