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's inject() function requires an active injection context. With useDefineForClassFields: 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)). With useDefineForClassFields: 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 useDefineForClassFields option
- If not provided, read it from the project's tsconfig.json (following the extends chain)
- 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 lowering
- test_lower_fields_with_super — lowering with super() call
- test_static_fields_not_lowered — static fields preserved
- test_no_constructor_creates_one — constructor created when missing
- test_no_constructor_with_super_class — constructor with super(...args) for subclasses
- test_private_fields_lowered — private field declaration kept, initializer moved
- test_private_fields_declaration_kept_mixed — mixed private/regular fields
- test_lowered_fields_before_existing_constructor_body — ordering verification
- test_fields_without_initializer_not_lowered — declaration-only fields preserved
Integration tests (in integration_test.rs):
- test_class_field_lowering_basic — full pipeline with @Component
- test_class_field_lowering_disabled_by_default — no lowering when option is true
- test_class_field_lowering_with_inheritance — extends + super() + private fields
- test_class_field_lowering_directive — @Directive classes
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 untouched
Context
- @oxc-angular/vite version: 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
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 CasesComponentThe <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.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's inject() function requires an active injection context. With useDefineForClassFields: 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)). With useDefineForClassFields: 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
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:
This matches TypeScript's tsc behavior exactly.
Affected Patterns
Any Angular component/directive/service that:
Suggested Implementation
1. Add useDefineForClassFields option to TransformOptions
Rust (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.rs with a lower_class_fields() function that:
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):
The plugin should:
4. Suggested tests
Unit tests (in class_field_lowering.rs):
Integration tests (in integration_test.rs):
Verification
Context