Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
23f8098e63 fix(transpiler): support useDefineForClassFields: false in tsconfig.json (#2060)
When `useDefineForClassFields` is set to `false` in tsconfig.json, type-only
class field declarations (fields with no initializer) should be stripped from
the output. Previously, Bun always emitted them as class field definitions
(e.g., `stuff;`), which caused them to be initialized to `undefined` at
runtime, overwriting values set by a parent constructor via `Object.assign`.

This change:
- Wires up the `useDefineForClassFields` tsconfig option through the full
  pipeline: tsconfig -> resolver -> transpiler -> parser
- Strips type-only instance fields from class bodies when the option is false
- Moves initialized instance fields to constructor assignments when the
  option is false and a constructor exists
- Preserves static fields, private fields, and methods unchanged

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-19 10:15:24 +00:00
9 changed files with 345 additions and 3 deletions

View File

@@ -12,7 +12,7 @@ pub const Parser = struct {
keep_names: bool = true,
ignore_dce_annotations: bool = false,
preserve_unused_imports_ts: bool = false,
use_define_for_class_fields: bool = false,
use_define_for_class_fields: bool = true,
suppress_warnings_about_weird_code: bool = true,
filepath_hash_for_hmr: u32 = 0,
features: RuntimeFeatures = .{},

View File

@@ -682,7 +682,6 @@ pub fn Visit(
}
}
// note: our version assumes useDefineForClassFields is true
if (comptime is_typescript_enabled) {
if (constructor_function) |constructor| {
var to_add: usize = 0;
@@ -744,6 +743,95 @@ pub fn Visit(
constructor.func.body.stmts = stmts.items;
}
}
// When "useDefineForClassFields" is false (legacy TypeScript behavior):
// - Type-only fields (no initializer) are stripped entirely
// - Instance fields with initializers are moved to constructor assignments
if (!p.options.use_define_for_class_fields) {
// Count instance fields with initializers that need to be moved to constructor
var instance_field_init_count: usize = 0;
for (class.properties) |property| {
if (property.flags.contains(.is_method) or property.kind == .class_static_block) continue;
if (property.flags.contains(.is_static)) continue;
// Skip private fields - they don't interact with prototype setters
if (property.key != null and @as(Expr.Tag, property.key.?.data) == .e_private_identifier) continue;
if (property.initializer != null) {
instance_field_init_count += 1;
}
}
// Move instance field initializers to the constructor if there's one
if (instance_field_init_count > 0 and constructor_function != null) {
const ctor = constructor_function.?;
var ctor_stmts = std.array_list.Managed(Stmt).fromOwnedSlice(p.allocator, ctor.func.body.stmts);
ctor_stmts.ensureUnusedCapacity(instance_field_init_count) catch unreachable;
// Find super() call index for derived classes
var ctor_super_index: ?usize = null;
if (class.extends != null) {
for (ctor_stmts.items, 0..) |stmt, index| {
if (stmt.data == .s_expr and stmt.data.s_expr.value.data == .e_call and stmt.data.s_expr.value.data.e_call.target.data == .e_super) {
ctor_super_index = index;
break;
}
}
}
var insert_offset: usize = 0;
for (class.properties) |property| {
if (property.flags.contains(.is_method) or property.kind == .class_static_block) continue;
if (property.flags.contains(.is_static)) continue;
if (property.key != null and @as(Expr.Tag, property.key.?.data) == .e_private_identifier) continue;
if (property.initializer) |init_val| {
const key_loc = if (property.key) |k| k.loc else logger.Loc{};
const field_name: []const u8 = if (property.key) |k| switch (k.data) {
.e_string => |s| s.string(p.allocator) catch "",
.e_identifier => |ident| p.symbols.items[ident.ref.innerIndex()].original_name,
else => "",
} else "";
ctor_stmts.insert(
if (ctor_super_index) |k| insert_offset + k + 1 else insert_offset,
Stmt.assign(
p.newExpr(E.Dot{
.target = p.newExpr(E.This{}, key_loc),
.name = field_name,
.name_loc = key_loc,
}, key_loc),
init_val,
),
) catch unreachable;
insert_offset += 1;
}
}
ctor.func.body.stmts = ctor_stmts.items;
}
// Remove non-private, non-static instance fields from class body.
// Type-only fields (no initializer) are just type annotations.
// Fields with initializers have been moved to the constructor above.
// Keep: methods, static blocks, static fields, private fields.
var write_idx: usize = 0;
for (class.properties) |property| {
if (property.flags.contains(.is_method) or property.kind == .class_static_block) {
class.properties[write_idx] = property;
write_idx += 1;
continue;
}
if (property.flags.contains(.is_static)) {
class.properties[write_idx] = property;
write_idx += 1;
continue;
}
if (property.key != null and @as(Expr.Tag, property.key.?.data) == .e_private_identifier) {
class.properties[write_idx] = property;
write_idx += 1;
continue;
}
// Non-private, non-static instance field: remove from class body
}
class.properties = class.properties[0..write_idx];
}
}
}

View File

@@ -234,6 +234,7 @@ pub fn transpileSourceCode(
.jsx = jsc_vm.transpiler.options.jsx,
.emit_decorator_metadata = jsc_vm.transpiler.options.emit_decorator_metadata,
.experimental_decorators = jsc_vm.transpiler.options.experimental_decorators,
.use_define_for_class_fields = jsc_vm.transpiler.options.use_define_for_class_fields orelse true,
.virtual_source = virtual_source,
.dont_bundle_twice = true,
.allow_commonjs = true,

View File

@@ -387,6 +387,7 @@ pub const RuntimeTranspilerStore = struct {
.jsx = transpiler.options.jsx,
.emit_decorator_metadata = transpiler.options.emit_decorator_metadata,
.experimental_decorators = transpiler.options.experimental_decorators,
.use_define_for_class_fields = transpiler.options.use_define_for_class_fields orelse true,
.virtual_source = null,
.dont_bundle_twice = true,
.allow_commonjs = true,

View File

@@ -32,6 +32,7 @@ known_target: options.Target,
module_type: options.ModuleType = .unknown,
emit_decorator_metadata: bool = false,
experimental_decorators: bool = false,
use_define_for_class_fields: bool = true,
ctx: *BundleV2,
package_version: string = "",
is_entry_point: bool = false,
@@ -120,6 +121,7 @@ pub fn init(resolve_result: *const _resolver.Result, source_index: Index, ctx: *
.module_type = resolve_result.module_type,
.emit_decorator_metadata = resolve_result.flags.emit_decorator_metadata,
.experimental_decorators = resolve_result.flags.experimental_decorators,
.use_define_for_class_fields = resolve_result.flags.use_define_for_class_fields,
.package_version = if (resolve_result.package_json) |package_json| package_json.version else "",
.known_target = ctx.transpiler.options.target,
};
@@ -1215,6 +1217,7 @@ fn runWithSourceCode(
opts.features.minify_whitespace = transpiler.options.minify_whitespace;
opts.features.emit_decorator_metadata = task.emit_decorator_metadata;
opts.features.standard_decorators = !loader.isTypeScript() or !task.experimental_decorators;
opts.use_define_for_class_fields = task.use_define_for_class_fields;
opts.features.unwrap_commonjs_packages = transpiler.options.unwrap_commonjs_packages;
opts.features.bundler_feature_flags = transpiler.options.bundler_feature_flags;
opts.features.hot_module_reloading = output_format == .internal_bake_dev and !source.index.isRuntime();

View File

@@ -1741,6 +1741,7 @@ pub const BundleOptions = struct {
jsx: JSX.Pragma = JSX.Pragma{},
emit_decorator_metadata: bool = false,
experimental_decorators: bool = false,
use_define_for_class_fields: ?bool = null,
auto_import_jsx: bool = true,
allow_runtime: bool = true,

View File

@@ -172,7 +172,7 @@ pub const Result = struct {
preserve_unused_imports_ts: bool = false,
emit_decorator_metadata: bool = false,
experimental_decorators: bool = false,
_padding: u1 = 0,
use_define_for_class_fields: bool = true,
};
pub const Union = union(enum) {
@@ -1002,6 +1002,9 @@ pub const Resolver = struct {
result.jsx = tsconfig.mergeJSX(result.jsx);
result.flags.emit_decorator_metadata = result.flags.emit_decorator_metadata or tsconfig.emit_decorator_metadata;
result.flags.experimental_decorators = result.flags.experimental_decorators or tsconfig.experimental_decorators;
if (tsconfig.use_define_for_class_fields) |v| {
result.flags.use_define_for_class_fields = v;
}
}
// If you use mjs or mts, then you're using esm

View File

@@ -472,6 +472,9 @@ pub const Transpiler = struct {
}
transpiler.options.emit_decorator_metadata = tsconfig.emit_decorator_metadata;
transpiler.options.experimental_decorators = tsconfig.experimental_decorators;
if (tsconfig.use_define_for_class_fields) |v| {
transpiler.options.use_define_for_class_fields = v;
}
}
}
}
@@ -640,6 +643,7 @@ pub const Transpiler = struct {
.jsx = resolve_result.jsx,
.emit_decorator_metadata = resolve_result.flags.emit_decorator_metadata,
.experimental_decorators = resolve_result.flags.experimental_decorators,
.use_define_for_class_fields = resolve_result.flags.use_define_for_class_fields,
},
client_entry_point_,
) orelse {
@@ -963,6 +967,7 @@ pub const Transpiler = struct {
set_breakpoint_on_first_line: bool = false,
emit_decorator_metadata: bool = false,
experimental_decorators: bool = false,
use_define_for_class_fields: bool = true,
remove_cjs_module_wrapper: bool = false,
dont_bundle_twice: bool = false,
@@ -1104,6 +1109,7 @@ pub const Transpiler = struct {
opts.features.emit_decorator_metadata = this_parse.emit_decorator_metadata;
opts.features.standard_decorators = !loader.isTypeScript() or !this_parse.experimental_decorators;
opts.use_define_for_class_fields = this_parse.use_define_for_class_fields;
opts.features.allow_runtime = transpiler.options.allow_runtime;
opts.features.set_breakpoint_on_first_line = this_parse.set_breakpoint_on_first_line;
opts.features.trim_unused_imports = transpiler.options.trim_unused_imports orelse loader.isTypeScript();

View File

@@ -0,0 +1,239 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
test("useDefineForClassFields: false strips type-only fields", async () => {
using dir = tempDir("issue-2060", {
"index.ts": `
class Base {
constructor(data: any) {
Object.assign(this, data);
}
}
class Outer extends Base {
stuff: string;
things: number;
extra: any;
}
class Inner extends Base {
more: string;
greatness: boolean;
}
let outer = new Outer({
stuff: "Hello World",
things: 42,
extra: new Inner({
more: "Bun is becoming great!",
greatness: true
})
});
console.log(JSON.stringify(outer));
`,
"tsconfig.json": JSON.stringify({
compilerOptions: {
useDefineForClassFields: false,
},
}),
});
await using proc = Bun.spawn({
cmd: [bunExe(), "run", "index.ts"],
cwd: String(dir),
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const result = JSON.parse(stdout.trim());
expect(result.stuff).toBe("Hello World");
expect(result.things).toBe(42);
expect(result.extra.more).toBe("Bun is becoming great!");
expect(result.extra.greatness).toBe(true);
expect(exitCode).toBe(0);
});
test("useDefineForClassFields: false moves initialized fields to constructor", async () => {
using dir = tempDir("issue-2060-init", {
"index.ts": `
class Foo {
x: number = 42;
y: string = "hello";
constructor() {
// With useDefineForClassFields: false, field initializers
// should be moved into the constructor as assignments.
}
}
const foo = new Foo();
console.log(JSON.stringify({ x: foo.x, y: foo.y }));
`,
"tsconfig.json": JSON.stringify({
compilerOptions: {
useDefineForClassFields: false,
},
}),
});
await using proc = Bun.spawn({
cmd: [bunExe(), "run", "index.ts"],
cwd: String(dir),
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const result = JSON.parse(stdout.trim());
expect(result.x).toBe(42);
expect(result.y).toBe("hello");
expect(exitCode).toBe(0);
});
test("useDefineForClassFields: false - fields don't override parent constructor assignments", async () => {
using dir = tempDir("issue-2060-override", {
"index.ts": `
class Base {
constructor(data: Record<string, any>) {
Object.assign(this, data);
}
}
class Child extends Base {
name: string;
value: number;
}
const child = new Child({ name: "test", value: 123 });
// With useDefineForClassFields: false, type-only fields should be stripped,
// so Object.assign values should be preserved (not overwritten to undefined).
console.log(JSON.stringify({ name: child.name, value: child.value }));
`,
"tsconfig.json": JSON.stringify({
compilerOptions: {
useDefineForClassFields: false,
},
}),
});
await using proc = Bun.spawn({
cmd: [bunExe(), "run", "index.ts"],
cwd: String(dir),
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const result = JSON.parse(stdout.trim());
expect(result.name).toBe("test");
expect(result.value).toBe(123);
expect(exitCode).toBe(0);
});
test("useDefineForClassFields: true (default) keeps fields in class body", async () => {
using dir = tempDir("issue-2060-default", {
"index.ts": `
class Base {
constructor(data: any) {
Object.assign(this, data);
}
}
class Child extends Base {
name: string;
}
const child = new Child({ name: "test" });
// With useDefineForClassFields: true (default), type-only fields remain,
// causing them to be initialized to undefined, overwriting Object.assign values.
console.log(JSON.stringify({ name: child.name }));
`,
"tsconfig.json": JSON.stringify({
compilerOptions: {
useDefineForClassFields: true,
},
}),
});
await using proc = Bun.spawn({
cmd: [bunExe(), "run", "index.ts"],
cwd: String(dir),
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// With define semantics, the field declaration overwrites the parent's assignment
const result = JSON.parse(stdout.trim());
expect(result.name).toBeUndefined();
expect(exitCode).toBe(0);
});
test("useDefineForClassFields: false - transpile output strips type-only fields", async () => {
using dir = tempDir("issue-2060-build", {
"index.ts": `
class Foo {
name: string;
value: number;
method() { return 1; }
}
`,
"tsconfig.json": JSON.stringify({
compilerOptions: {
useDefineForClassFields: false,
},
}),
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "--no-bundle", "index.ts"],
cwd: String(dir),
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Type-only fields should not appear in the output
expect(stdout).not.toContain("name;");
expect(stdout).not.toContain("value;");
// Method should still be present
expect(stdout).toContain("method()");
expect(exitCode).toBe(0);
});
test("useDefineForClassFields: false keeps static fields", async () => {
using dir = tempDir("issue-2060-static", {
"index.ts": `
class Foo {
static count: number = 0;
name: string;
}
console.log(JSON.stringify({ count: Foo.count }));
`,
"tsconfig.json": JSON.stringify({
compilerOptions: {
useDefineForClassFields: false,
},
}),
});
await using proc = Bun.spawn({
cmd: [bunExe(), "run", "index.ts"],
cwd: String(dir),
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const result = JSON.parse(stdout.trim());
expect(result.count).toBe(0);
expect(exitCode).toBe(0);
});