BlogBlog · typescript

TypeScript decorators chuẩn ECMAScript và tăng tốc type-check: dùng thế nào cho đúng

Phân biệt decorator legacy (NestJS, TypeORM) với decorator chuẩn TC39, tự viết method/class/accessor decorator và metadata, đo chi phí runtime, chẩn đoán tsc chậm, isolatedDeclarations và cách dùng TypeScript 7 bản Go.

Part of Tech Radar series
April 5, 2026
23 min read
By Leo Pham
typescriptdecoratorsperformancetoolingnodejs
Share this post:

Hai câu hỏi mình nhận nhiều nhất từ team backend năm nay đều xoay quanh TypeScript, nhưng nghe như không liên quan. Câu thứ nhất: "Decorator kiểu @Injectable() của NestJS với decorator 'chuẩn' mà TypeScript 5.0 hỗ trợ có phải là một không, có nên chuyển không?" Câu thứ hai: "Monorepo của em tsc --noEmit mất gần hai phút, có cách nào ngoài mua máy mới?"

Chúng liên quan hơn bạn nghĩ. Cả hai đều là chuyện compiler làm gì với code của bạn: decorator quyết định code gì được sinh ra và chạy lúc runtime, còn type-check chậm là chuyện compiler tốn bao nhiêu thời gian để hiểu type của bạn. Và năm 2026 có thay đổi lớn ở cả hai phía: decorator chuẩn đã ổn định trong TypeScript từ bản 5.0, còn compiler viết lại bằng Go đã ra bản chính thức là TypeScript 7.0 vào tháng 7/2026.

Bài này giúp bạn: phân biệt hai hệ decorator và biết vì sao không trộn được, tự viết decorator chuẩn cho method, class, accessor và metadata, biết khi nào decorator làm chậm hot path, chẩn đoán type-check chậm bằng công cụ có sẵn, và quyết định có nên lên TypeScript 7 ngay không.

TL;DR:

  • experimentalDecorators (legacy) và decorator chuẩn TC39 là hai hệ khác signature, khác ngữ nghĩa. Một project chỉ chọn được một. NestJS, TypeORM vẫn dùng legacy.
  • Decorator chuẩn có signature (value, context), có context.addInitializer, có accessor, có metadata qua Symbol.metadata (TS 5.2), nhưng không có parameter decorator và không tự sinh type metadata.
  • Type-check chậm: đo bằng --extendedDiagnostics, tìm thủ phạm bằng --generateTrace, rồi mới sửa (project references, skipLibCheck, đơn giản hoá type, isolatedDeclarations).
  • TypeScript 7.0 (compiler Go) đã GA từ 8/7/2026. Chưa có API ổn định, nên typescript-eslint và các tool dùng API vẫn phải chạy với TypeScript 6.0 song song.

Hai hệ decorator: legacy và chuẩn TC39

TypeScript có decorator từ 2015, dựa trên một bản đề xuất cũ. Bản đó cần bật "experimentalDecorators": true. Sau đó đề xuất decorator ở TC39 thay đổi gần như hoàn toàn và lên Stage 3 năm 2022. TypeScript 5.0 (tháng 3/2023) hỗ trợ bản Stage 3 này: không bật experimentalDecorators thì @ nghĩa là decorator chuẩn.

// ===== Legacy: cần "experimentalDecorators": true =====
// Signature: (target, propertyKey, descriptor)
function LogLegacy(target: object, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value as (...args: unknown[]) => unknown
  descriptor.value = function (this: unknown, ...args: unknown[]) {
    console.log(`call ${propertyKey}`)
    return original.apply(this, args)
  }
}

// NestJS dựa vào emitDecoratorMetadata: TypeScript ghi type của tham số constructor
// vào "design:paramtypes" để DI container biết cần inject OrdersService
// @Injectable()
// export class OrdersController {
//   constructor(private readonly ordersService: OrdersService) {}
// }

// ===== Chuẩn TC39: KHÔNG bật experimentalDecorators =====
// Signature: (value, context)
function logStandard<This, Args extends unknown[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) {
  return function (this: This, ...args: Args): Return {
    console.log(`call ${String(context.name)}`)
    return target.call(this, ...args)
  }
}

Hai hệ này không thay thế được cho nhau, vì ba lý do cụ thể:

  1. Signature khác. Legacy nhận (target, key, descriptor) và sửa descriptor. Chuẩn nhận (value, context) và trả về giá trị thay thế. Một decorator viết cho hệ này gọi trong hệ kia sẽ báo lỗi type hoặc chạy sai.
  2. Chuẩn không có parameter decorator. constructor(@Inject(TOKEN) private repo: Repo) không tồn tại trong spec TC39. NestJS, Angular kiểu cũ, InversifyJS dựa vào nó.
  3. Chuẩn không sinh type metadata. emitDecoratorMetadata chỉ hoạt động với legacy. Đây là cơ chế giúp DI của NestJS "tự biết" inject gì. Decorator chuẩn có metadata, nhưng là metadata bạn tự ghi, không phải type do compiler sinh.

Flag là của cả compilation, nên một project không trộn được hai hệ. Nếu bạn dùng NestJS, TypeORM, class-validator, class-transformer: giữ experimentalDecorators và emitDecoratorMetadata, đừng cố chuyển. Nếu viết code mới không phụ thuộc các thư viện đó: dùng decorator chuẩn.

Tiêu chí Legacy (experimentalDecorators) Chuẩn TC39 (TS 5.0+)
Bật bằng "experimentalDecorators": true Mặc định khi không bật flag legacy
Signature (target, key, descriptor) (value, context)
Parameter decorator Có Không
Type metadata tự sinh Có, với emitDecoratorMetadata + reflect-metadata Không
Metadata tự ghi Qua reflect-metadata context.metadata + Symbol.metadata (TS 5.2)
addInitializer Không Có
accessor field Không Có
Hệ sinh thái chính NestJS, TypeORM, class-validator, InversifyJS Lit 3, code nội bộ, thư viện mới
Build tool tsc, SWC, Babel (plugin legacy), esbuild tsc, Babel (version: "2023-11"), esbuild, SWC

Viết decorator chuẩn: method, class và accessor

Dưới đây là một file decorator mình dùng thật, đã chạy với tsc --target es2022. Chú ý generic nằm ở hàm bên trong của decorator factory, để TypeScript suy ra This, Args, Return từ method được decorate.

// decorators.ts — decorator chuẩn TC39, KHÔNG bật experimentalDecorators

export function logDuration(label?: string) {
  return function <This, Args extends unknown[], Return>(
    target: (this: This, ...args: Args) => Return,
    context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
  ) {
    const name = label ?? String(context.name)
    return function (this: This, ...args: Args): Return {
      const startedAt = performance.now()
      const report = () =>
        console.log(`[timing] ${name} ${(performance.now() - startedAt).toFixed(1)}ms`)
      try {
        const result = target.call(this, ...args)
        if (result instanceof Promise) return result.finally(report) as Return
        report()
        return result
      } catch (error) {
        report()
        throw error
      }
    }
  }
}

export function memoize<This extends object, Arg extends string | number, Return>(
  target: (this: This, arg: Arg) => Return,
  _context: ClassMethodDecoratorContext<This, (this: This, arg: Arg) => Return>,
) {
  const cacheByInstance = new WeakMap<This, Map<Arg, Return>>()
  return function (this: This, arg: Arg): Return {
    let cache = cacheByInstance.get(this)
    if (!cache) {
      cache = new Map()
      cacheByInstance.set(this, cache)
    }
    if (cache.has(arg)) return cache.get(arg) as Return
    const value = target.call(this, arg)
    cache.set(arg, value)
    // Không cache promise bị reject, nếu không lỗi tạm thời sẽ bị nhớ mãi
    if (value instanceof Promise) value.catch(() => cache.delete(arg))
    return value
  }
}

export function bound<This extends object, Args extends unknown[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) {
  if (context.private) throw new Error('@bound không dùng cho private method')
  context.addInitializer(function (this: This) {
    Object.defineProperty(this, context.name, {
      value: target.bind(this),
      writable: true,
      configurable: true,
    })
  })
}

type EventHandlerClass = new () => { handle(payload: unknown): Promise<void> }
export const handlerRegistry = new Map<string, EventHandlerClass>()

export function handlesEvent(eventName: `order.${string}`) {
  return function <T extends EventHandlerClass>(_target: T, context: ClassDecoratorContext<T>) {
    context.addInitializer(function (this: T) {
      if (handlerRegistry.has(eventName)) throw new Error(`Trùng handler cho ${eventName}`)
      handlerRegistry.set(eventName, this)
    })
  }
}

export function clamp(min: number, max: number) {
  const fit = (value: number) => Math.min(max, Math.max(min, value))
  return function <This>(
    target: ClassAccessorDecoratorTarget<This, number>,
    _context: ClassAccessorDecoratorContext<This, number>,
  ): ClassAccessorDecoratorResult<This, number> {
    return {
      init: (initial) => fit(initial),
      get() {
        return target.get.call(this)
      },
      set(value) {
        target.set.call(this, fit(value))
      },
    }
  }
}

Và cách dùng:

// usage.ts
import { bound, clamp, handlerRegistry, handlesEvent, logDuration, memoize } from './decorators.js'

export class OrderService {
  @logDuration('OrderService.getOrder')
  async getOrder(orderId: string) {
    await new Promise((resolve) => setTimeout(resolve, 20))
    return { orderId, status: 'paid' as const }
  }

  @memoize
  shippingZoneOf(provinceCode: string): 'north' | 'central' | 'south' {
    console.log('tính zone cho', provinceCode)
    return provinceCode < '40' ? 'north' : provinceCode < '60' ? 'central' : 'south'
  }

  @bound
  handleWebhook(payload: { orderId: string }) {
    return this.getOrder(payload.orderId)
  }
}

export class Coupon {
  @clamp(0, 100) accessor discountPercent = 0
}

@handlesEvent('order.paid')
export class SendReceiptHandler {
  async handle(payload: unknown) {
    console.log('gửi biên nhận', payload)
  }
}

const service = new OrderService()
service.shippingZoneOf('01')
service.shippingZoneOf('01') // lấy từ cache, không log lần hai
const { handleWebhook } = service // tách method khỏi object vẫn giữ this nhờ @bound
await handleWebhook({ orderId: 'ord_42' })

const coupon = new Coupon()
coupon.discountPercent = 150
console.log(coupon.discountPercent) // 100
console.log([...handlerRegistry.keys()]) // [ 'order.paid' ]

Những điểm cần hiểu để không viết sai:

  • Method decorator trả về hàm thay thế. Không còn descriptor. Muốn biết tên, static hay private, đọc từ context.name, context.static, context.private.
  • context.addInitializer đăng ký code chạy lúc khởi tạo. Với method decorator, nó chạy mỗi lần tạo instance (đầu constructor). Với class decorator, nó chạy một lần sau khi class định nghĩa xong, và this là chính class. @bound và @handlesEvent khai thác đúng hai thời điểm đó.
  • accessor (từ khoá có từ TS 4.9) tạo cặp getter/setter trên một private field ẩn. Decorator trên accessor nhận { get, set } gốc và trả về { get, set, init }. Decorator trên field thường (không accessor) chỉ có thể trả về hàm biến đổi giá trị khởi tạo, không chặn được phép gán sau đó. Muốn validate mỗi lần gán thì phải dùng accessor.
  • Decorator chạy lúc class được định nghĩa, tức là lúc module được import. @handlesEvent chỉ đăng ký nếu file chứa SendReceiptHandler được import ở đâu đó. Registry pattern dựa trên decorator luôn cần một file "barrel" import tất cả handler, nếu không handler sẽ âm thầm biến mất.

Chạy được ở đâu? Đừng trông chờ runtime chạy decorator nguyên bản: Node.js 24 mình thử vẫn báo SyntaxError ngay ở ký tự @, nên code phải được biên dịch xuống. tsc làm việc đó với mọi target hiện có (decorator chưa có trong ES2025). tsx và esbuild xử lý được decorator chuẩn. Type stripping của Node.js thì không, vì decorator là cú pháp JavaScript chứ không phải type. Với Vite 8, migration guide ghi rõ Oxc không lower decorator chuẩn, cần thêm plugin Babel (@rolldown/plugin-babel) hoặc SWC.

Decorator metadata (TS 5.2) và Symbol.metadata

Decorator chuẩn không sinh type metadata, nhưng từ TypeScript 5.2 có context.metadata: một object dùng chung cho mọi decorator trong cùng class, gắn vào class qua Class[Symbol.metadata]. Đây là cách viết validation, serialization, routing bằng decorator mà không cần reflect-metadata.

// polyfill-symbol-metadata.ts — import ĐẦU TIÊN ở entry point
;(Symbol as { metadata?: symbol }).metadata ??= Symbol('Symbol.metadata')
export {}
// validation.ts
type Rule = (value: unknown) => string | null
const RULES = Symbol('validation-rules')

function ownRules(metadata: DecoratorMetadataObject): Map<PropertyKey, Rule[]> {
  // metadata của class con kế thừa (qua prototype) từ class cha:
  // copy trước khi sửa để không ghi đè rule của class cha
  if (!Object.hasOwn(metadata, RULES)) {
    const inherited = metadata[RULES] as Map<PropertyKey, Rule[]> | undefined
    metadata[RULES] = new Map(inherited)
  }
  return metadata[RULES] as Map<PropertyKey, Rule[]>
}

function addRule(rule: Rule) {
  return function <This, Value>(
    _target: ClassAccessorDecoratorTarget<This, Value>,
    context: ClassAccessorDecoratorContext<This, Value>,
  ) {
    if (!context.metadata) throw new Error('Thiếu Symbol.metadata: import polyfill trước khi định nghĩa class')
    const rules = ownRules(context.metadata)
    rules.set(context.name, [...(rules.get(context.name) ?? []), rule])
  }
}

export const required = addRule((v) => (v === '' || v == null ? 'bắt buộc' : null))
export const minLength = (n: number) =>
  addRule((v) => (typeof v === 'string' && v.length < n ? `tối thiểu ${n} ký tự` : null))

export function validate(instance: object): Record<string, string[]> {
  const ctor = instance.constructor as { [Symbol.metadata]?: DecoratorMetadataObject | null }
  const rules = ctor[Symbol.metadata]?.[RULES] as Map<PropertyKey, Rule[]> | undefined
  const errors: Record<string, string[]> = {}
  for (const [field, fieldRules] of rules ?? []) {
    const value: unknown = Reflect.get(instance, field)
    const messages = fieldRules.map((rule) => rule(value)).filter((m): m is string => m !== null)
    if (messages.length > 0) errors[String(field)] = messages
  }
  return errors
}

// main.ts
// import './polyfill-symbol-metadata.js'
// class CustomerForm {
//   @required @minLength(2) accessor fullName = ''
//   @required accessor phone = ''
// }
// class BusinessCustomerForm extends CustomerForm {
//   @required accessor taxCode = ''
// }
// validate(new BusinessCustomerForm())
// -> { fullName: [...], phone: ['bắt buộc'], taxCode: ['bắt buộc'] }

Yêu cầu runtime cần nhớ:

  • Symbol.metadata phải tồn tại trước khi class được định nghĩa. Mình thử trên Node.js 24 thì Symbol.metadata vẫn là undefined. Code TypeScript sinh ra kiểm tra Symbol.metadata ngay trong static block của class; nếu chưa có, context.metadata sẽ là undefined và metadata bị bỏ qua mà không báo lỗi gì. Vì vậy polyfill phải là import đầu tiên của entry point, và decorator nên tự kiểm tra như addRule ở trên.
  • Lib phải có esnext.decorators (hoặc esnext) để TypeScript biết type của Symbol.metadata và DecoratorMetadataObject.
  • Metadata kế thừa qua prototype. Object metadata của class con có prototype là metadata của class cha. Đọc thì thấy cả rule của cha (đúng ý), nhưng nếu bạn lấy Map của cha rồi set thẳng vào thì sẽ sửa luôn class cha. Object.hasOwn + copy trong ownRules giải quyết chuyện đó.
  • Thứ tự áp dụng là từ dưới lên. @required @minLength(2) chạy minLength trước rồi mới tới required. Nếu thứ tự rule quan trọng (ví dụ dừng ở lỗi đầu tiên), hãy thiết kế cho điều đó.

Chi phí runtime của decorator trong hot path

Decorator không miễn phí. Đây là output (rút gọn) mà tsc --target es2022 sinh ra cho một class có đúng một method decorator:

let OrderService = (() => {
  let _instanceExtraInitializers = []
  let _getOrder_decorators
  return class OrderService {
    static {
      const _metadata = typeof Symbol === 'function' && Symbol.metadata ? Object.create(null) : void 0
      _getOrder_decorators = [logged]
      __esDecorate(this, null, _getOrder_decorators, { kind: 'method', name: 'getOrder' /* ... */ }, null, _instanceExtraInitializers)
      if (_metadata) Object.defineProperty(this, Symbol.metadata, { /* ... */ value: _metadata })
    }
    getOrder(id) { return { id } }
    constructor() {
      __runInitializers(this, _instanceExtraInitializers)
    }
  }
})()

Nhìn vào đó, chi phí đến từ bốn chỗ:

  1. Mỗi lần gọi method đi qua hàm wrapper: thêm một call frame, rest/spread ...args tạo mảng mới, cộng với việc wrapper tự làm (performance.now(), tra Map). Với method gọi vài lần mỗi request, không đáng kể. Với method gọi cho từng phần tử trong vòng lặp trăm nghìn lần, đáng để đo.
  2. Mỗi lần tạo instance chạy __runInitializers. @bound tạo thêm một bound function cho mỗi instance, tức bộ nhớ tăng theo số object. Đặt @bound trên class entity mà bạn tạo hàng trăm nghìn instance là một ý tồi.
  3. Helper __esDecorate và __runInitializers được chèn vào mọi file dùng decorator. Bật "importHelpers": true và cài tslib để chúng chỉ có một bản.
  4. Cache trong decorator như @memoize không có giới hạn kích thước. WeakMap theo instance giúp cache được thu hồi khi instance bị GC, nhưng một singleton service sống suốt đời process sẽ giữ mọi key từng gặp. Với key không giới hạn (user ID, query string), dùng LRU có giới hạn thay vì memoize ngây thơ.

Quy tắc của mình: decorator dùng ở ranh giới (controller, handler, service method gọi I/O), nơi chi phí của một lần gọi là micro giây so với mili giây của I/O. Không dùng trên hàm tính toán thuần chạy trong vòng lặp nóng, và không dùng trên class mà bạn tạo hàng loạt. Nếu nghi ngờ, profile bằng node --cpu-prof và tìm wrapper của decorator trong flame graph thay vì đoán.

Chẩn đoán type-check chậm

Đổi sang phần thứ hai. Khi tsc --noEmit chậm, đừng đoán. Đo theo thứ tự này:

# 1. Tổng quan: thời gian từng pha, số type, số instantiation
npx tsc -p tsconfig.json --noEmit --extendedDiagnostics

# 2. Vì sao file này nằm trong program? (hay gặp: @types kéo cả thế giới vào)
npx tsc -p tsconfig.json --noEmit --explainFiles > explain-files.txt

# 3. Trace chi tiết: file nào, type nào tốn thời gian check
npx tsc -p tsconfig.json --noEmit --generateTrace ./.ts-trace
npx @typescript/analyze-trace ./.ts-trace
# Mở ./.ts-trace/trace.json trong https://ui.perfetto.dev để xem timeline

Output của --extendedDiagnostics trông như thế này (output thật từ một project nhỏ, rút gọn; số của bạn sẽ khác):

Files:                         165
Lines of Library:            51126
Lines of Definitions:        32254
Lines of TypeScript:           132
Types:                       32449
Instantiations:              78366
Memory used:               170084K
Parse time:                  0.35s
Bind time:                   0.12s
Check time:                  0.31s
Total time:                  0.93s

Đọc nó thế nào:

  • Lines of Definitions lớn gấp nhiều lần Lines of TypeScript: bạn đang check rất nhiều .d.ts. Kiểm tra --explainFiles, giới hạn types trong tsconfig, và bật skipLibCheck.
  • Check time chiếm phần lớn và Instantiations lên hàng triệu: có type đắt. Dùng analyze-trace để tìm "hot spots": nó in ra file và vị trí mà checker tốn nhiều thời gian nhất.
  • Parse time và Program time lớn: quá nhiều file trong program. Thu hẹp include, tách project.

Các cách sửa, theo thứ tự hiệu quả trên chi phí mình hay gặp:

skipLibCheck: true. Bỏ qua việc check nội bộ các file .d.ts. Đổi lại, xung đột giữa hai bản @types khác nhau sẽ không bị báo. Hầu hết project nên bật; thư viện publish type thì nên có một job CI tắt nó.

incremental: true. tsc ghi file .tsbuildinfo và lần sau chỉ check lại phần thay đổi. Trên CI, cache file đó giữa các lần chạy (ví dụ với actions/cache theo hash của lockfile và tsconfig).

Project references với tsc --build. Tách monorepo thành các project có composite: true, khai báo references, rồi chạy tsc -b. Project không đổi thì không check lại, và project phụ thuộc chỉ đọc .d.ts của project kia thay vì check lại source.

// packages/domain/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "isolatedDeclarations": true,
    "rootDir": "src",
    "outDir": "dist",
    "tsBuildInfoFile": "dist/.tsbuildinfo"
  },
  "include": ["src"]
}

// packages/api/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": { "composite": true, "rootDir": "src", "outDir": "dist" },
  "include": ["src"],
  "references": [{ "path": "../domain" }]
}

// tsconfig.json ở root: chỉ để điều phối
{
  "files": [],
  "references": [{ "path": "packages/domain" }, { "path": "packages/api" }]
}

Đơn giản hoá type đắt. Những mẫu hay làm checker "đốt" thời gian:

  • Union khổng lồ, đặc biệt là template literal type nhân chéo (${Locale}/${Section}/${Page} với vài chục giá trị mỗi vế sẽ ra hàng chục nghìn thành viên). TypeScript báo lỗi TS2590 "union type that is too complex to represent" khi vượt giới hạn, nhưng trước khi tới đó nó đã chậm rồi.
  • Conditional type đệ quy sâu (deep path, deep partial, parser ở cấp type).
  • Intersection lớn của nhiều object type. Wiki Performance của TypeScript khuyên dùng interface ... extends thay vì & vì quan hệ giữa interface được cache.
  • Hàm export không có return type: checker phải suy ra (và in ra .d.ts) type đó mỗi lần. Ghi return type rõ ràng ở public API giúp cả tốc độ lẫn độ ổn định của API.

isolatedDeclarations (TS 5.5): sinh .d.ts không cần type checker

isolatedDeclarations bắt buộc mọi thứ được export phải có type đủ rõ để sinh file .d.ts chỉ từ chính file đó, không cần suy luận qua file khác.

// tsconfig: "declaration": true, "isolatedDeclarations": true
export const DEFAULT_CURRENCY = 'VND' // OK: literal tự mô tả

export function calculateVatVnd(amountVnd: number, vatRate: number) {
  return Math.round(amountVnd * vatRate)
}
// error TS9007: Function must have an explicit return type annotation with --isolatedDeclarations.

export const shippingZones = ['north', 'central', 'south'].map((zone) => zone.toUpperCase())
// error TS9010: Variable must have an explicit type annotation with --isolatedDeclarations.

// Sửa:
export function calculateVatVndFixed(amountVnd: number, vatRate: number): number {
  return Math.round(amountVnd * vatRate)
}
export const shippingZonesFixed: string[] = ['north', 'central', 'south'].map((zone) =>
  zone.toUpperCase(),
)

Cái nó mở ra: công cụ không phải TypeScript (Oxc, SWC và các bundler dựa trên chúng) có thể sinh .d.ts song song, từng file một, nhanh hơn nhiều so với chạy tsc --emitDeclarationOnly. Trong monorepo dùng project references, project phụ thuộc không phải đợi project kia type-check xong mới có .d.ts. Bài công bố TypeScript 7.0 cũng nhắc --isolatedDeclarations là trường hợp giúp build project references song song tốt hơn.

Cái giá: phải viết thêm annotation ở mọi export. Editor có quick fix "Add annotation" để thêm hàng loạt. Mình chỉ bật nó cho package thư viện trong monorepo (nơi .d.ts là sản phẩm), không bật cho app.

TypeScript bản Go: từ preview tsgo tới TypeScript 7.0

Dòng thời gian, theo blog chính thức của team TypeScript:

  • 3/2025: công bố port compiler sang Go. Bản preview phát hành qua package @typescript/native-preview, chạy bằng lệnh tsgo.
  • 23/3/2026: TypeScript 6.0, bản cuối cùng dựa trên codebase JavaScript. Nó là bản "cầu nối": đổi default (strict: true, module: esnext, types: []...) và deprecate hoặc gỡ hàng loạt option cũ (target: es5, moduleResolution: node10, baseUrl, outFile...).
  • 8/7/2026: TypeScript 7.0 chính thức. Gói typescript trên npm giờ cài compiler Go, lệnh vẫn là tsc. Các option bị deprecate ở 6.0 thành lỗi cứng.
  • 20/10/2026: TypeScript 7.1 beta, mang API mới cho tool.

Con số nhanh hơn bao nhiêu? Bài công bố 7.0 đưa ra số đo của team trên một số codebase lớn mã nguồn mở, ví dụ type-check codebase VS Code từ 125,7 giây xuống 10,6 giây. Đó là số của họ, trên máy của họ. Đo trên codebase của bạn bằng cách dưới đây trước khi hứa với ai.

# Thử TS 7 mà không sửa package.json
time npx -p typescript@7 tsc -p tsconfig.json --noEmit
time npx -p typescript@6 tsc -p tsconfig.json --noEmit

# TS 7 chạy song song: --checkers (mặc định 4) cho type-check,
# --builders cho project references; hai số này nhân với nhau về bộ nhớ
npx -p typescript@7 tsc -p tsconfig.json --noEmit --checkers 8
npx -p typescript@7 tsc --build --builders 2 --checkers 4

Nếu TS 7 báo lỗi về option, gần như chắc chắn là option đã bị deprecate từ 6.0. Lộ trình an toàn: lên 6.0, sửa hết cảnh báo mà không dùng ignoreDeprecations, khai báo rõ types (ví dụ ["node"]) vì default giờ là mảng rỗng, rồi mới thử 7.0.

Cái chưa có ở 7.0: API. Bản 7.0 không có programmatic API ổn định. Mọi tool import ts from 'typescript' để đọc AST hay hỏi type checker (typescript-eslint typed linting, ts-morph, một số plugin framework) vẫn cần TypeScript 6. Peer dependency của typescript-eslint hiện là typescript >=4.8.4 <6.1.0. Team TypeScript khuyến nghị cài song song bằng npm alias:

{
  "devDependencies": {
    "@typescript/native": "npm:typescript@^7.0.2",
    "typescript": "npm:@typescript/typescript6@^6.0.2"
  }
}

Với cấu hình này, npx tsc là 7.0 (nhanh, dùng cho type-check trong CI và editor), npx tsc6 là 6.0, và mọi tool import typescript nhận API của 6.0. Bài công bố cũng ghi rõ type-check template của Vue, Svelte, Astro, MDX và Angular chưa dùng được 7.0 vì phụ thuộc API 6.0.

Còn decorator? Mục tiêu của 7.0 là cho kết quả check và emit giống 6.0, và bảng tình trạng trong repo typescript-go đánh dấu JS emit là hoàn thành. Nhưng bài công bố không nhắc riêng tới experimentalDecorators hay emitDecoratorMetadata. Nếu app của bạn là NestJS/TypeORM build bằng tsc, hãy build bằng 7.0, khởi động app và chạy test integration/E2E để chắc DI và entity mapping vẫn đúng trước khi đổi pipeline. Nhiều project NestJS build bằng SWC thì không bị ảnh hưởng bởi đổi compiler, chỉ đổi bước type-check.

Editor: extension VS Code TypeScriptTeam.native-preview cho language server của TypeScript 7.

Nên làm gì bây giờ

Tình huống Khuyến nghị
NestJS, TypeORM, class-validator Giữ legacy decorator + emitDecoratorMetadata. Không chuyển sang chuẩn cho tới khi framework hỗ trợ
Viết decorator nội bộ mới, không phụ thuộc framework trên Dùng decorator chuẩn, metadata qua Symbol.metadata
Frontend build bằng Vite 8 dùng decorator chuẩn Thêm plugin Babel/SWC, vì Oxc không lower decorator
Type-check chậm, chưa đo --extendedDiagnostics và --generateTrace trước, sửa sau
Monorepo nhiều package Project references + incremental, cân nhắc isolatedDeclarations cho package thư viện
Node/React app không dùng Vue/Svelte/Astro/Angular template Dùng TS 7 cho type-check CI ngay, giữ TS 6 qua alias cho typescript-eslint
Cần type-check template Vue/Svelte/Astro/Angular trong editor Chờ tooling hỗ trợ API mới (TS 7.1+), tạm thời giữ 6.0

Những lỗi thường gặp

1. Dùng thư viện legacy mà quên bật flag.

// Sai: class-validator viết cho legacy decorator, project lại không bật experimentalDecorators
// import { IsString } from 'class-validator'
// class CreateOrderDto {
//   @IsString() note!: string
// }
// -> error TS1240: Unable to resolve signature of property decorator when called as an expression.

// Đúng: tsconfig của project dùng class-validator
// { "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true } }

2. import type phá DI của NestJS. Với emitDecoratorMetadata, TypeScript cần tham chiếu runtime tới class để ghi vào design:paramtypes. Đổi sang import type (thường do autofix của rule consistent-type-imports) khiến metadata thành Object và Nest không inject được.

// Sai: chỉ còn type, metadata mất class OrdersService
// import type { OrdersService } from './orders.service'

// Đúng: import giá trị
// import { OrdersService } from './orders.service'

typescript-eslint đã điều chỉnh consistent-type-imports để không autofix sai trong trường hợp có legacy decorator và emitDecoratorMetadata. Kiểm tra lại cấu hình rule nếu bạn đang dùng bản cũ.

3. Polyfill Symbol.metadata import sau class. Metadata im lặng biến mất, validate() trả về object rỗng và mọi form đều "hợp lệ". Đặt polyfill là import đầu tiên, và cho decorator ném lỗi khi context.metadata là undefined.

4. Bật skipLibCheck: false "cho chắc" ở mọi nơi. Check lại hàng chục nghìn dòng .d.ts của node_modules trên mỗi lần chạy, để bắt một loại lỗi hiếm. Bật skipLibCheck: true cho app; chỉ thư viện publish type mới cần một job riêng tắt nó.

5. Thử TS 7 bằng cách chỉ đổi version trong package.json. typescript-eslint, ts-morph và mọi tool dùng API sẽ vỡ vì TS 7.0 không có API cũ. Dùng alias như ở trên.

Checklist

  • Xác định project đang dùng hệ decorator nào (experimentalDecorators trong tsconfig) và ghi rõ trong README.
  • Với code mới không phụ thuộc NestJS/TypeORM, viết decorator theo signature (value, context).
  • Nếu dùng context.metadata, thêm polyfill Symbol.metadata làm import đầu tiên và lib esnext.decorators.
  • Bật importHelpers + cài tslib nếu nhiều file dùng decorator.
  • Rà decorator trên hot path và trên class tạo hàng loạt instance; đo bằng --cpu-prof.
  • Chạy tsc --noEmit --extendedDiagnostics và lưu kết quả làm baseline.
  • Bật skipLibCheck và incremental, cache .tsbuildinfo trên CI.
  • Lên TypeScript 6.0, sửa hết deprecation không dùng ignoreDeprecations, khai báo types rõ ràng.
  • Đo tsc của TypeScript 7 trên codebase của bạn; nếu dùng, cài song song với TS 6 qua npm alias.

Đọc thêm

Last updated: April 5, 2026

Comments