Support useDefineForClassFields: false (Class Field Lowering) #73
Description
Activity
@Brooooooklyn thanks for the fix attemp, but It did not solve the problem for me, here is what did solved:
The Problem
Angular projects use
useDefineForClassFields: falsein their tsconfig to get legacy [[Set]] semantics for class fields. This is critical because:- Constructor parameter properties (e.g.,
protected userService: UserService) are assigned in the constructor body - Class field initializers that reference
inject()results or constructor parameter properties must run inside the constructor body in declaration order - With native class fields (
useDefineForClassFields: true), field initializers run aftersuper()returns but before the constructor body — this breaks when fields reference constructor parameter properties or other injected fields
Runtime Error
TypeError: Cannot read properties of undefined (reading 'onClose') at <instance_members_initializer> (my.component.ts:42:56)The
<instance_members_initializer>in the V8 stack trace confirms native class fields are being used at runtime.Affected Patterns
// Pattern 1: inject() cross-reference (CRASHES) private panelService = inject(PanelService); #drawerCloseEvent$ = this.panelService.onClose.pipe(delay(0)); // Pattern 2: Constructor parameter property reference (CRASHES) private isAdmin = this.userService.isAdmin(); // Pattern 3: Arrow function fields referencing `this` initSession = (userId: string | null = null): void => { const name = this.getUserName(userId); };
Why PR #82 (
a6e98f0) Doesn't WorkPR #82 added this to the plugin's
confighook:...(options.tsconfig && { build: { rolldownOptions: { tsconfig: options.tsconfig, }, }, }),
This forwards the tsconfig to
build.rolldownOptions.tsconfig, delegating class field lowering to Rolldown/Vite's built-in OXC transformer. However, this approach fundamentally cannot work due to the plugin execution order:The Transform Pipeline
1. @oxc-angular/vite (order: 'pre') ├── Parses TypeScript ├── Compiles Angular templates/decorators (→ ɵɵdefineComponent, etc.) ├── Strips TypeScript types (access modifiers, type annotations) └── Outputs JAVASCRIPT with native class fields still intact 2. vite:oxc (built-in, runs after 'pre' plugins) ├── Receives the already-transformed JavaScript code ├── File ID is still `.ts`, so the filter matches ├── Calls transformWithOxc() → Rolldown's transformSync() └── BUT: the code is already JavaScript — no TypeScript syntax remains → Class field lowering is NOT applied to JS contentThree Specific Reasons
-
build.rolldownOptionsis for the build path (production bundling), not dev serve. During dev serve, Vite 8'svite:oxcplugin usestransformWithOxc()which resolves tsconfig per-file automatically. -
The OXC Angular plugin runs first (
order: 'pre') and outputs JavaScript. By the timevite:oxcruns, all TypeScript syntax has been stripped. -
Class field lowering is a TypeScript transform — it's triggered by
useDefineForClassFields: falsein tsconfig, which only applies when processing TypeScript. Even if Rolldown'stransformSyncreads the tsconfig, it won't lower class fields in JavaScript content because that's a TS→JS lowering step.
The Fix: Class Field Lowering in the OXC Angular Compiler
Since the OXC Angular plugin is the one that strips TypeScript and outputs JavaScript, it must also handle class field lowering. The fix adds a new
useDefineForClassFieldsoption toTransformOptionsand implements class field lowering as a post-processing step intransform_angular_file().Architecture
transform_angular_file() ├── Parse TypeScript ├── Extract Angular metadata ├── Compile templates/decorators ├── Filter imports, remove decorators ├── Insert ɵcmp/ɵfac definitions └── NEW: If useDefineForClassFields == false └── lower_class_fields() ← moves field initializers to constructorLowering Rules
Field type Class body Constructor body field = valueRemove declaration Add this.field = value;#field = valueKeep as #field;Add this.#field = value;static field = valueKeep as-is Do NOT move field;(no init)Keep as-is Do NOT move field = () => { ... }Remove declaration Add this.field = () => { ... };Constructor Body Ordering
super()call (if present)- Lowered field initializer assignments (in declaration order)
- Original constructor body statements
Implementation Details
The lowering uses a sorted edits approach to avoid position corruption:
- Parse the final transformed code
- Collect all edits (field removals, initializer stripping, constructor insertions) with their original source positions
- Sort edits by position in descending order
- Apply edits from end to start, ensuring earlier edits don't shift positions of later edits
This is critical because naive sequential modification (insert assignments, then remove fields) corrupts byte positions and produces invalid output like
computele());fromcomputed<boolean>().Files Changed
File Change crates/oxc_angular_compiler/src/component/class_field_lowering.rsNew module implementing lower_class_fields()crates/oxc_angular_compiler/src/component/mod.rsRegister the new module crates/oxc_angular_compiler/src/component/transform.rsAdd use_define_for_class_fieldstoTransformOptions, call loweringnapi/angular-compiler/src/lib.rsAdd useDefineForClassFieldsto NAPITransformOptionsnapi/angular-compiler/index.d.tsAdd TypeScript type definition napi/angular-compiler/vite-plugin/index.tsRead useDefineForClassFieldsfrom tsconfig, pass to compilerVite Plugin Changes
The Vite plugin now:
- Reads
useDefineForClassFieldsfrom the project's tsconfig (following theextendschain) - Passes it to the Rust compiler via
TransformOptions - The Rust compiler performs class field lowering as part of
transform_angular_file()
// In the plugin's angular() function: const useDefineForClassFields = options.tsconfig ? readUseDefineForClassFields(resolve(workspaceRoot, options.tsconfig)) : undefined; // In the transform hook: const transformOptions: TransformOptions = { sourcemap: pluginOptions.sourceMap, jit: pluginOptions.jit, hmr: pluginOptions.liveReload && watchMode, useDefineForClassFields, // ← NEW };
Testing
Unit Tests (11 tests in
class_field_lowering.rs)test_lower_regular_fields— basic field loweringtest_lower_private_fields— ES private fields (#field)test_skip_static_fields— static fields not loweredtest_super_call_ordering— assignments aftersuper()test_no_constructor_generates_one— auto-generate constructortest_no_constructor_with_super_class— auto-generate withsuper(...args)test_fields_without_initializer_not_lowered—field;kept as-istest_angular_inject_pattern— full Angular inject cross-reference patterntest_arrow_function_field— arrow function class fieldstest_generic_call_in_field—computed<boolean>()preservedtest_optional_chaining_in_field—this.service?.getData()preserved
Integration Tests (4 tests in
integration_test.rs)test_class_field_lowering_with_inject_pattern— full Angular component with inject, private fields, signalstest_class_field_lowering_not_applied_by_default— no lowering when option is not settest_class_field_lowering_with_super_class— ordering withsuper()calltest_class_field_lowering_static_fields_untouched— static fields preserved
Manual Testing Checklist
-
Dev serve (
npm run dev):- Components with
inject()cross-references load without errors - Components extending parent classes work correctly
- Arrow function class fields work correctly
-
computed<T>()andsignal<T>()fields work correctly - Optional chaining in field initializers works
- Static fields (ɵfac, ɵcmp) are NOT lowered
- HMR still works for template/style changes
- Components with
-
Production build (
npm run build):- Build completes without errors
- Runtime behavior matches dev serve
-
Edge cases:
- Classes without constructors get auto-generated constructors
- Classes extending parent classes get
super(...args)forwarding - Multiple components in the same file all get lowered
- Non-Angular classes in the same file also get lowered
- Files without Angular decorators are not processed (early return)
Verification Script
const { transformAngularFileSync } = require('@oxc-angular/vite/api'); const code = ` import { Component, inject, signal } from '@angular/core'; class MyService { onClose: any; } @Component({ selector: 'app-test', template: '<div/>', standalone: true }) export class TestComponent { private svc = inject(MyService); #event$ = this.svc.onClose; #view = signal('home'); view = this.#view.asReadonly(); constructor() { console.log(this.#view()); } } `; const result = transformAngularFileSync(code, 'test.ts', { sourcemap: false, jit: false, hmr: false, useDefineForClassFields: false }, { templates: {}, styles: {} } ); console.log(result.code); // Expected: field initializers moved to constructor body after super() // #event$; and #view; declarations kept in class body // Static ɵfac/ɵcmp fields untouched
Why Private Fields Need Special Handling
ES private fields use a "private name" slot that must be established via a class-level declaration. Unlike regular properties, you cannot dynamically create
#fields:SyntaxError: Private field '#field' must be declared in an enclosing classSo for private fields, we keep the declaration (
#field;) but move only the initializer to the constructor (this.#field = value;).- Constructor parameter properties (e.g.,
You can either:
- Set the
useDefineForClassFields: falseto yourtsconfig.json, Vite will resolve the nearesttsconfig.jsonthat"include"the files that compiling.
OR
- Add your
tsconfig.app.jsonto thereferencesfield in yourtsconfig.json
OR
- Add this to your vite.config.ts:
export default defineConfig({ oxc: { typescript: { removeClassFieldsWithoutInitializer: true, }, assumptions: { setPublicClassFields: true, } }, })
- Set the
The author suggested three options. Here is the analysis of each, with a minimal reproduction and test results.
Minimal Reproduction
// vite.config.ts import { angular } from '@oxc-angular/vite'; import { defineConfig } from 'vite'; export default defineConfig({ plugins: [ angular({ tsconfig: './tsconfig.app.json' }) ] });
// tsconfig.json (root) { "compilerOptions": { "useDefineForClassFields": false, // ← Angular standard "experimentalDecorators": true, "target": "ES2022" } }
// tsconfig.app.json { "extends": "./tsconfig.json" }
// app.component.ts — crashes at runtime import { Component, inject } from '@angular/core'; import { delay } from 'rxjs'; class PanelService { onClose = new Subject<void>(); } @Component({ selector: 'app-root', template: '', standalone: true }) export class AppComponent { // Pattern 1: public field cross-reference (crashes) private panelService = inject(PanelService); readonly events$ = this.panelService.onClose.pipe(delay(0)); // Pattern 2: private # field cross-reference (crashes even harder) #events$ = this.panelService.onClose.pipe(delay(0)); }
Runtime error:
TypeError: Cannot read properties of undefined (reading 'onClose') at <instance_members_initializer> (app.component.ts:12:34)The
<instance_members_initializer>in the V8 stack trace confirms native class fields are being used —panelServiceisundefinedwhenevents$initializes.
Option 1: Set
useDefineForClassFields: falsein tsconfig.json"Vite will resolve the nearest tsconfig.json that 'include' the files that compiling."
Result: ❌ Does NOT work
useDefineForClassFields: falseis already set in our roottsconfig.json(inherited by all project tsconfigs). Vite's built-invite:oxcplugin does resolve the tsconfig per-file viaTsconfigCache. However, the problem is the plugin execution order:@oxc-angular/viteruns first (order: 'pre') — it strips all TypeScript syntax and outputs JavaScript with native class fields intactvite:oxcruns after — it receives JavaScript, not TypeScript. Class field lowering is a TypeScript→JavaScript transform triggered byuseDefineForClassFields: false. Since the code is already JavaScript, the tsconfig setting has no effect.
The tsconfig is read correctly, but it's too late — the TypeScript has already been stripped.
Option 2: Add
tsconfig.app.jsonto thereferencesfield intsconfig.json"Add your tsconfig.app.json to the references field in your tsconfig.json"
Result: ❌ Does NOT work
Our
tsconfig.jsonalready hasreferencespointing totsconfig.app.json. This is the standard Angular monorepo setup. Thereferencesfield is for TypeScript project references (used bytsc --build), not for Vite's tsconfig resolution. Vite resolves tsconfig by walking up the directory tree from each source file, not viareferences. This option does not change the plugin execution order problem described in Option 1.
Option 3: Add
oxcconfig tovite.config.tsexport default defineConfig({ oxc: { typescript: { removeClassFieldsWithoutInitializer: true }, assumptions: { setPublicClassFields: true } } })
Result: ✅ Partially works — fixes public fields, does NOT fix private
#fieldsWe applied this option. Here is what happens:
The
oxcconfig is passed to Vite's built-invite:oxcplugin, which callsrolldown/utilstransformSync. Even though@oxc-angular/vitehas already stripped TypeScript and output JavaScript,transformSyncstill applies theassumptions.setPublicClassFieldstransform to JavaScript content.The two options work together:
setPublicClassFields: true— moves public field initializers (field = value) to the constructor body asthis.field = valueremoveClassFieldsWithoutInitializer: true— removes the barefield;declaration thatsetPublicClassFieldsleaves behind (without this, you get a double-define viaObject.defineProperty)
Verified with
rolldown/utilstransformSyncdirectly:// Input (JavaScript output from @oxc-angular/vite): class AppComponent { panelService = new PanelService(); // public field #events$ = this.panelService.onClose; // private # field } // Output with both options: class AppComponent { #events$ = this.panelService.onClose; // ← STILL a native field (not lowered) constructor() { this.panelService = new PanelService(); // ← lowered ✅ } }
Why
#fields are not fixed:setPublicClassFieldsonly applies to public fields. ES private fields (#field) require a class-level declaration slot — you cannot dynamically create them. So#events$ = this.panelService.onCloseremains a native class field initializer that runs before the constructor body, andthis.panelServiceis stillundefinedat that point.Workaround for
#field cross-references: Declare the#field without an initializer and assign it in the constructor:// Before (crashes): private panelService = inject(PanelService); #events$ = this.panelService.onClose.pipe(delay(0)); // After (works): private panelService = inject(PanelService); #events$!: Observable<void>; constructor() { this.#events$ = this.panelService.onClose.pipe(delay(0)); }
Summary Table
Option Status Notes 1. useDefineForClassFields: falsein tsconfig❌ Not effective Already set; vite:oxcreceives JS not TS2. Add tsconfig.app.jsontoreferences❌ Not effective Already done; doesn't change plugin order 3. oxc: { assumptions: { setPublicClassFields }, typescript: { removeClassFieldsWithoutInitializer } }⚠️ PartialFixes public fields; #private fields still brokenThe complete fix requires the OXC Angular compiler to implement class field lowering natively in Rust (inside
transform_angular_file()), so that both public and private fields are lowered before TypeScript is stripped.@Brooooooklyn could you please take a look at it?
Also wanted to mention -
Our app is very large and contains a lot of components, including legacy code and NgModules. Because of that, it’s really impressive to see this working so well. It significantly reduced our build times.The fact that this compiler can successfully serve and build such a large application is very promising.
While testing it, I encountered several edge cases and complex scenarios and opened a few issues, as you’ve seen. This is one of the last runtime bugs we’re still facing.
In my local fork I resolved the issue by implementing Class Field Lowering in Rust, and I can confirm that it fixes the runtime errors we observed.
I also attached a simple reproduction so anyone (or even an AI) can reproduce and investigate the problem easily.
Huge thanks for this project. I’m excited to see where it goes.
@tomer953 I'm discussing it with the Rolldown team
BTW, can you share the performance changes in your project?
Thanks 🙏
I don't mind - but privately for now, if you want to DM on twitter
@tomer953 have you tried this?
export default defineConfig({ oxc: { target: 'es2015', typescript: { removeClassFieldsWithoutInitializer: true }, assumptions: { setPublicClassFields: true } } })
I mentioned that I tried all three options and mentioned what was wrong with each
However I noticed you added
target: 'es2015In the last comment, Im not sure I tried with it
Do you want me to check?
Do you want me to check?
Yes, adding
target: 'es2015'should lower the syntax for private fields.Reacted by Tomer953seems it fixed the problem... good for me at this point
but I'm not sure we want all the consumers to pass it right?
I mean its a standard angular syntax with standard angular configThe oxc angular compiler only handles Angular-related compilation, such as HTML templates, CSS, and Angular built-in decorators.
In simple terms, after oxc-angular-compiler compiles, it is still TypeScript, and the step of compiling from TypeScript to js is completed by Vite/Rolldown.- added 2 commits that reference this issue
on Apr 2, 2026
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Summary
The OXC Angular compiler (
@oxc-angular/vite) outputs native ES class fields without lowering them to constructor assignments. This causes runtime errors in Angular projects that useuseDefineForClassFields: falsein their tsconfig — which is the standard Angular configuration.Runtime Errors
Error 1: Properties of undefined
The
<instance_members_initializer>in the V8 stack trace confirms that native class fields are being used at runtime, when they should have been lowered to constructor assignments.Error 2: Private field SyntaxError
This occurs when private fields (
#field) are completely removed from the class body during lowering — ES private fields require a class-level declaration for the private name slot.Root Cause
The project's
tsconfig.base.jsonhas:{ "compilerOptions": { "target": "ES2022", "useDefineForClassFields": false } }With
useDefineForClassFields: false, TypeScript lowers class field initializers into the constructor body as assignments (legacy behavior). This is critical for Angular because:inject()in class fields: Angular'sinject()function requires an active injection context. WithuseDefineForClassFields: false,inject()calls in class fields are lowered to constructor body assignments, where the injection context is guaranteed to be active.Constructor parameter properties: Angular components often use constructor DI (
constructor(private router: Router)). WithuseDefineForClassFields: false, parameter properties are assigned before class field initializers in the constructor body. With native class fields, field initializers run before parameter property assignments.Inheritance: When a component extends a parent class, the parent's constructor sets properties via parameter properties. With
useDefineForClassFields: false, the child's field initializers can safely reference these properties because they run after the parent constructor AND after the child's parameter property assignments.Minimal Reproduction
Input TypeScript:
Current OXC Output (WRONG — keeps native class fields):
Expected OXC Output (with
useDefineForClassFields: false):Lowering Rules
field = value)this.field = value;#field = value)#field;(no initializer)this.#field = value;static field = value)field;)declarefieldWhy private fields need special handling
ES private fields use a "private name" slot that must be established via a class-level declaration. Unlike regular properties, you cannot dynamically create
#fields — the browser throwsSyntaxError: Private field '#field' must be declared in an enclosing class.Constructor body ordering
When lowering, the order in the constructor must be:
super()call (if present)This matches TypeScript's
tscbehavior exactly.Affected Patterns
Any Angular component/directive/service that:
inject()in a class field AND has another field that references the injected service#field) with initializersSuggested Implementation
1. Add
useDefineForClassFieldsoption toTransformOptionsRust (
crates/oxc_angular_compiler/src/component/transform.rs):NAPI (
napi/angular-compiler/src/lib.rs):2. Implement class field lowering pass
Create a new module
crates/oxc_angular_compiler/src/component/class_field_lowering.rswith alower_class_fields()function that:#field;(no initializer)this.field = value;assignment statementssuper()if present, before existing body)super(...args)for subclasses)Call this pass at the end of
transform_angular_file()whenuse_define_for_class_fieldsisfalse.3. Wire through Vite plugin
Read from tsconfig (
napi/angular-compiler/vite-plugin/index.ts):The plugin should:
useDefineForClassFieldsoptiontsconfig.json(following theextendschain)TransformOptions4. Suggested tests
Unit tests (in
class_field_lowering.rs):test_lower_simple_class_fields— basic field loweringtest_lower_fields_with_super— lowering withsuper()calltest_static_fields_not_lowered— static fields preservedtest_no_constructor_creates_one— constructor created when missingtest_no_constructor_with_super_class— constructor withsuper(...args)for subclassestest_private_fields_lowered— private field declaration kept, initializer movedtest_private_fields_declaration_kept_mixed— mixed private/regular fieldstest_lowered_fields_before_existing_constructor_body— ordering verificationtest_fields_without_initializer_not_lowered— declaration-only fields preservedIntegration tests (in
integration_test.rs):test_class_field_lowering_basic— full pipeline with@Componenttest_class_field_lowering_disabled_by_default— no lowering when option is truetest_class_field_lowering_with_inheritance—extends+super()+ private fieldstest_class_field_lowering_directive—@DirectiveclassesVerification
Context
@oxc-angular/viteversion:0.0.88.0.0-beta.16(uses Rolldown for bundling)order: 'pre'useDefineForClassFields: false