Repository navigation
Support useDefineForClassFields: false (Class Field Lowering) #73
Description
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 use useDefineForClassFields: false in their tsconfig — which is the standard Angular configuration.
Runtime Errors
Error 1: Properties of undefined
TypeError: Cannot read properties of undefined (reading 'onClose')
at <instance_members_initializer> ...
at new CasesComponent
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
SyntaxError: Private field '#view' must be declared in an enclosing class
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.json has:
{
"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:
import { Component, inject } from '@angular/core';
class ParentClass {
protected constructor(protected dep: SomeService) {}
}
class CifaPanelService {
onClose: Observable<boolean>;
}
@Component({
selector: 'app-cases',
template: '<div>cases</div>',
standalone: true
})
export class CasesComponent extends ParentClass {
// Field using inject()
private cifaPanelService = inject(CifaPanelService);
// Field referencing the inject() field above — CRASHES with native class fields
#casesDrawerCloseChangeEvent$ = this.cifaPanelService.onClose.pipe(delay(0));
// Private field with signal
#view = signal<string>('home');
view = this.#view.asReadonly();
constructor(protected dep: SomeService) {
super(dep);
console.log(this.#view());
}
}Current OXC Output (WRONG — keeps native class fields):
export class CasesComponent extends ParentClass {
// These are NATIVE class fields — they run after super() but BEFORE constructor body
cifaPanelService = inject(CifaPanelService);
#casesDrawerCloseChangeEvent$ = this.cifaPanelService.onClose.pipe(delay(0));
#view = signal('home');
view = this.#view.asReadonly();
constructor(dep) {
super(dep);
console.log(this.#view());
}
static ɵfac = function CasesComponent_Factory(__ngFactoryType__) { ... };
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ ... });
}Expected OXC Output (with useDefineForClassFields: false):
export class CasesComponent extends ParentClass {
#casesDrawerCloseChangeEvent$; // ← Private field declaration KEPT
#view; // ← Private field declaration KEPT
constructor(dep) {
super(dep);
// Field initializers lowered to assignments (BEFORE existing constructor body)
this.cifaPanelService = inject(CifaPanelService);
this.#casesDrawerCloseChangeEvent$ = this.cifaPanelService.onClose.pipe(delay(0));
this.#view = signal('home');
this.view = this.#view.asReadonly();
// Original constructor body
console.log(this.#view());
}
static ɵfac = function CasesComponent_Factory(__ngFactoryType__) { ... };
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ ... });
}Lowering Rules
| Field type | Class body | Constructor body |
|---|---|---|
Regular field (field = value) |
Remove declaration entirely | Add this.field = value; |
ES private field (#field = value) |
Keep declaration as #field; (no initializer) |
Add this.#field = value; |
Static field (static field = value) |
Keep as-is (no lowering) | Do NOT move |
Field without initializer (field;) |
Keep as-is (no lowering) | Do NOT move |
declare field |
Keep as-is (no lowering) | Do NOT move |
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 — the browser throws SyntaxError: 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)- Lowered field initializer assignments (in declaration order)
- Original constructor body statements
This matches TypeScript's tsc behavior exactly.
Affected Patterns
Any Angular component/directive/service that:
- Uses
inject()in a class field AND has another field that references the injected service - References a constructor parameter property in a class field initializer
- Extends a parent class and references parent properties in class field initializers
- Uses ES private fields (
#field) with initializers
Suggested Implementation
1. Add useDefineForClassFields option to TransformOptions
Rust (crates/oxc_angular_compiler/src/component/transform.rs):
pub struct TransformOptions {
// ... existing fields ...
/// Controls whether class fields use `[[Define]]` semantics (native ES class fields)
/// or are lowered to constructor assignments.
///
/// When `true` (default), class fields are kept as native ES class fields.
/// When `false`, instance class field initializers are moved into the constructor body.
pub use_define_for_class_fields: bool,
}NAPI (napi/angular-compiler/src/lib.rs):
pub struct TransformOptions {
// ... existing fields ...
pub use_define_for_class_fields: Option<bool>,
}2. Implement class field lowering pass
Create a new module crates/oxc_angular_compiler/src/component/class_field_lowering.rs with a lower_class_fields() function that:
- Parses the final transformed code
- For each class, identifies instance property definitions (non-static, with initializers)
- For regular fields: removes the declaration entirely from the class body
- For private fields: replaces the declaration with
#field;(no initializer) - Builds
this.field = value;assignment statements - Inserts assignments into the constructor body (after
super()if present, before existing body) - If no constructor exists, creates one (with
super(...args)for subclasses)
Call this pass at the end of transform_angular_file() when use_define_for_class_fields is false.
3. Wire through Vite plugin
Read from tsconfig (napi/angular-compiler/vite-plugin/index.ts):
export interface PluginOptions {
// ... existing options ...
useDefineForClassFields?: boolean;
}The plugin should:
- Accept an explicit
useDefineForClassFieldsoption - If not provided, read it from the project's
tsconfig.json(following theextendschain) - Pass it to the Rust compiler via
TransformOptions
4. 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 preserved
Integration 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—@Directiveclasses
Verification
const { transformAngularFileSync } = require('@oxc-angular/vite/api');
const code = `
import { Component, inject, signal } from '@angular/core';
class ParentClass {
constructor(protected dep: any) {}
}
class MyService { onClose: any; }
@Component({ selector: 'app-test', template: '<div/>', standalone: true })
export class TestComponent extends ParentClass {
private svc = inject(MyService);
#event$ = this.svc.onClose.pipe();
#view = signal('home');
view = this.#view.asReadonly();
constructor(protected dep: any) {
super(dep);
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 output should show:
// 1. #event$; and #view; declarations kept in class body
// 2. All initializers moved to constructor body after super()
// 3. Original console.log() after the lowered assignments
// 4. Static ɵfac/ɵcmp fields untouchedContext
@oxc-angular/viteversion:0.0.8- Vite version:
8.0.0-beta.16(uses Rolldown for bundling) - Angular version: 19/20
- The OXC Angular compiler is used as a Vite plugin with
order: 'pre' - Vite 8's built-in OXC transformer runs after the plugin and strips remaining TypeScript
- Angular's standard tsconfig uses
useDefineForClassFields: false
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
tomer953 commentedon Mar 5, 2026
@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:protected userService: UserService) are assigned in the constructor bodyinject()results or constructor parameter properties must run inside the constructor body in declaration orderuseDefineForClassFields: true), field initializers run aftersuper()returns but before the constructor body — this breaks when fields reference constructor parameter properties or other injected fieldsRuntime Error
The
<instance_members_initializer>in the V8 stack trace confirms native class fields are being used at runtime.Affected Patterns
Why PR #82 (
a6e98f0) Doesn't WorkPR #82 added this to the plugin's
confighook: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
Three 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
Lowering Rules
field = valuethis.field = value;#field = value#field;this.#field = value;static field = valuefield;(no init)field = () => { ... }this.field = () => { ... };Constructor Body Ordering
super()call (if present)Implementation Details
The lowering uses a sorted edits approach to avoid position corruption:
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
crates/oxc_angular_compiler/src/component/class_field_lowering.rslower_class_fields()crates/oxc_angular_compiler/src/component/mod.rscrates/oxc_angular_compiler/src/component/transform.rsuse_define_for_class_fieldstoTransformOptions, call loweringnapi/angular-compiler/src/lib.rsuseDefineForClassFieldsto NAPITransformOptionsnapi/angular-compiler/index.d.tsnapi/angular-compiler/vite-plugin/index.tsuseDefineForClassFieldsfrom tsconfig, pass to compilerVite Plugin Changes
The Vite plugin now:
useDefineForClassFieldsfrom the project's tsconfig (following theextendschain)TransformOptionstransform_angular_file()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()preservedIntegration 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 preservedManual Testing Checklist
Dev serve (
npm run dev):inject()cross-references load without errorscomputed<T>()andsignal<T>()fields work correctlyProduction build (
npm run build):Edge cases:
super(...args)forwardingVerification Script
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:So for private fields, we keep the declaration (
#field;) but move only the initializer to the constructor (this.#field = value;).Brooooooklyn commentedon Mar 6, 2026
You can either:
useDefineForClassFields: falseto yourtsconfig.json, Vite will resolve the nearesttsconfig.jsonthat"include"the files that compiling.OR
tsconfig.app.jsonto thereferencesfield in yourtsconfig.jsonOR
tomer953 commentedon Mar 7, 2026
The author suggested three options. Here is the analysis of each, with a minimal reproduction and test results.
Minimal Reproduction
Runtime error:
The
<instance_members_initializer>in the V8 stack trace confirms native class fields are being used —panelServiceisundefinedwhenevents$initializes.Option 1: Set
useDefineForClassFields: falsein tsconfig.jsonResult: ❌ 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.jsonResult: ❌ 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.tsResult: ✅ 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: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:Summary Table
useDefineForClassFields: falsein tsconfigvite:oxcreceives JS not TStsconfig.app.jsontoreferencesoxc: { assumptions: { setPublicClassFields }, typescript: { removeClassFieldsWithoutInitializer } }#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.tomer953 commentedon Mar 10, 2026
@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.
Brooooooklyn commentedon Mar 10, 2026
@tomer953 I'm discussing it with the Rolldown team
BTW, can you share the performance changes in your project?
tomer953 commentedon Mar 10, 2026
Thanks 🙏
I don't mind - but privately for now, if you want to DM on twitter
Brooooooklyn commentedon Mar 10, 2026
@tomer953 have you tried this?
tomer953 commentedon Mar 10, 2026
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?
Brooooooklyn commentedon Mar 10, 2026
Yes, adding
target: 'es2015'should lower the syntax for private fields.tomer953 commentedon Mar 10, 2026
seems 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 config
Brooooooklyn commentedon Mar 11, 2026
The 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.
fix: strip uninitialized class fields (useDefineForClassFields: false)
fix: strip uninitialized class fields (useDefineForClassFields: false)