fix(codegen): drop redundant whitespace in minified TypeScript output (#23038)

Removes redundant whitespace from minified output across many TypeScript constructs. Each change is minify-only: pretty-print output is byte-identical, all outputs round-trip, and a conservative `type_starts_with_non_identifier` helper only drops a space when the following token definitely begins with punctuation.

### Fixes

- **Decorators** — `@dec()class`, `export@dec class`, `accessor#x`
- **`as` / `satisfies`** — `foo()as X`, `` `s`as const ``, `as{a:1}`, `as(x)=>y`, `as[T]`
- **Non-null `!`** — `opts!.x`, `x!;`, `a!()`; the separator is kept only before `=`/`==`/`===` (which would retokenize as `!=`/`!==`), in both binary and assignment positions
- **`enum`** — `enum E{…}`
- **Conditional types** — `T extends[U]?X:Y`, `Foo<T>extends…`; the `? ?C` separator is preserved for a prefix JSDoc-nullable true branch
- **Heritage & constraints** — `class A<T>extends B<T>implements I`, `interface I<T=string>extends…`, `<T extends[a,b]>`
- **Type operators / constructors** — `readonly[…]`, `keyof[a,b]`, `readonly(keyof T)[]`, `new()=>T`, mapped-type `readonly[P in …]`, `export type*from`

### Verification

A whitespace analyzer over ~400 real-world `.ts` files went from **1410 → 4** removable spaces. The remaining 4 are a deliberate `i-->0` tie (handled elsewhere) and two higher-risk edge cases (`f<T>,` instantiation, mapped `in` before a template literal).

`oxc_codegen` and `oxc_minifier` suites pass, `just minsize` shows no drift, clippy is clean, and regression tests were added for each construct.
This commit is contained in:
Boshen committed 2026-06-07 02:42:28 +00:00
1 parent cf53285466
commit a4b1bf7337
5 files changed
+275 -49

No files matched your search

+128 -40
View File
@@ -984,7 +984,10 @@ impl Gen for ImportDeclaration<'_> {
let local_name = p.get_binding_identifier_name(&spec.local);
let imported_name = get_module_export_name(&spec.imported, p);
if imported_name != local_name {
p.print_str(" as ");
p.print_soft_space();
p.print_space_before_identifier();
p.print_str("as");
p.print_soft_space();
spec.local.print(p, ctx);
}
}
@@ -1055,7 +1058,12 @@ impl Gen for ExportNamedDeclaration<'_> {
p.add_source_mapping(self.span);
p.print_str("export");
if let Some(decl) = &self.declaration {
p.print_hard_space();
// A decorated class starts with `@`, so no space is needed after `export`.
if matches!(decl, Declaration::ClassDeclaration(c) if !c.decorators.is_empty()) {
p.print_soft_space();
} else {
p.print_hard_space();
}
match decl {
Declaration::VariableDeclaration(decl) => decl.print(p, ctx),
Declaration::FunctionDeclaration(decl) => decl.print(p, ctx),
@@ -1151,7 +1159,10 @@ impl Gen for ExportSpecifier<'_> {
let local_name = get_module_export_name(&self.local, p);
let exported_name = get_module_export_name(&self.exported, p);
if local_name != exported_name {
p.print_str(" as ");
p.print_soft_space();
p.print_space_before_identifier();
p.print_str("as");
p.print_soft_space();
if let Some(comments) = p.get_comments(self.exported.span().start) {
p.print_comments(&comments);
p.print_soft_space();
@@ -1179,15 +1190,15 @@ impl Gen for ExportAllDeclaration<'_> {
p.add_source_mapping(self.span);
p.print_str("export");
if self.export_kind.is_type() {
p.print_str(" type ");
} else {
p.print_soft_space();
p.print_str(" type");
}
p.print_soft_space();
p.print_ascii_byte(b'*');
if let Some(exported) = &self.exported {
p.print_soft_space();
p.print_str("as ");
p.print_str("as");
p.print_soft_space();
exported.print(p, ctx);
p.print_hard_space();
} else {
@@ -2023,6 +2034,14 @@ impl GenExpr for AssignmentExpression<'_> {
if !cjs_module_lexer::try_print_exports_computed_target(p, &self.left, ctx) {
self.left.print(p, ctx);
}
// `a! = b`: keep a separator so the non-null `!` doesn't glue to `=` as `!=`.
if p.options.minify
&& self.operator == AssignmentOperator::Assign
&& p.prev_op == Some(Operator::Unary(UnaryOperator::LogicalNot))
&& p.prev_op_end == p.code_len()
{
p.print_hard_space();
}
p.print_soft_space();
p.print_str(self.operator.as_str());
p.print_soft_space();
@@ -2385,7 +2404,14 @@ impl GenExpr for TSAsExpression<'_> {
p.wrap(wrap, |p| {
self.expression.print_expr(p, Precedence::Exponentiation, ctx);
p.print_str(" as ");
p.print_soft_space();
p.print_space_before_identifier();
p.print_str("as");
if type_starts_with_non_identifier(&self.type_annotation) {
p.print_soft_space();
} else {
p.print_hard_space();
}
self.type_annotation.print(p, ctx);
});
}
@@ -2397,7 +2423,14 @@ impl GenExpr for TSSatisfiesExpression<'_> {
p.wrap(wrap, |p| {
self.expression.print_expr(p, Precedence::Exponentiation, ctx);
p.print_str(" satisfies ");
p.print_soft_space();
p.print_space_before_identifier();
p.print_str("satisfies");
if type_starts_with_non_identifier(&self.type_annotation) {
p.print_soft_space();
} else {
p.print_hard_space();
}
self.type_annotation.print(p, ctx);
});
}
@@ -2407,9 +2440,9 @@ impl GenExpr for TSNonNullExpression<'_> {
fn gen_expr(&self, p: &mut Codegen, _precedence: Precedence, ctx: Context) {
self.expression.print_expr(p, Precedence::Postfix, ctx);
p.print_ascii_byte(b'!');
if p.options.minify {
p.print_hard_space();
}
// Remember the `!` so a following `=` doesn't retokenize as `!=`/`!==`.
p.prev_op = Some(Operator::Unary(UnaryOperator::LogicalNot));
p.prev_op_end = p.code().len();
}
}
@@ -2482,14 +2515,18 @@ impl Gen for Class<'_> {
type_parameters.print(p, ctx);
}
if let Some(super_class) = self.super_class.as_ref() {
p.print_str(" extends ");
p.print_soft_space();
p.print_space_before_identifier();
p.print_str("extends ");
super_class.print_expr(p, Precedence::Postfix, Context::empty());
if let Some(super_type_parameters) = &self.super_type_arguments {
super_type_parameters.print(p, ctx);
}
}
if !self.implements.is_empty() {
p.print_str(" implements ");
p.print_soft_space();
p.print_space_before_identifier();
p.print_str("implements ");
p.print_list(&self.implements, ctx);
}
p.print_soft_space();
@@ -2952,6 +2989,8 @@ impl Gen for AccessorProperty<'_> {
if self.computed {
p.print_soft_space();
p.print_ascii_byte(b'[');
} else if matches!(self.key, PropertyKey::PrivateIdentifier(_)) {
p.print_soft_space();
} else {
p.print_hard_space();
}
@@ -3318,6 +3357,33 @@ impl Gen for TSIntersectionType<'_> {
}
}
/// Whether `ty` prints starting with a non-identifier byte, so a preceding keyword
/// (`as`, `extends`, `keyof`, ...) needs no separating space when minifying.
/// Conservative: only types that unconditionally start with punctuation are listed,
/// so the worst case is keeping an unnecessary space, never dropping a required one.
fn type_starts_with_non_identifier(ty: &TSType) -> bool {
match ty {
TSType::TSTupleType(_)
| TSType::TSTypeLiteral(_)
| TSType::TSMappedType(_)
| TSType::TSFunctionType(_)
| TSType::TSParenthesizedType(_)
| TSType::TSTemplateLiteralType(_) => true,
TSType::TSArrayType(arr) => {
parenthesize_check_type_of_postfix_type(&arr.element_type)
|| type_starts_with_non_identifier(&arr.element_type)
}
TSType::TSLiteralType(lit) => {
matches!(&lit.literal, TSLiteral::TemplateLiteral(_) | TSLiteral::StringLiteral(_))
}
TSType::TSUnionType(u) => u.types.first().is_some_and(type_starts_with_non_identifier),
TSType::TSIntersectionType(i) => {
i.types.first().is_some_and(type_starts_with_non_identifier)
}
_ => false,
}
}
impl Gen for TSConditionalType<'_> {
fn r#gen(&self, p: &mut Codegen, ctx: Context) {
p.wrap(
@@ -3333,21 +3399,31 @@ impl Gen for TSConditionalType<'_> {
);
p.print_soft_space();
p.print_space_before_identifier();
p.print_str("extends ");
p.wrap(matches!(self.extends_type, TSType::TSConditionalType(_)), |p| {
p.print_str("extends");
let extends_wrapped = matches!(self.extends_type, TSType::TSConditionalType(_));
if extends_wrapped || type_starts_with_non_identifier(&self.extends_type) {
p.print_soft_space();
} else {
p.print_hard_space();
}
p.wrap(extends_wrapped, |p| {
self.extends_type.print(p, ctx);
});
p.print_soft_space();
// Avoid merging into `??` when the extends type ends with `?` (postfix
// JSDoc nullable, e.g. `A extends C? ? D : E`).
// Avoid `??` when the extends type ends with a postfix nullable `?`.
if p.last_byte() == Some(b'?') {
p.print_hard_space();
}
p.print_str("?");
p.print_soft_space();
p.print_ascii_byte(b'?');
// Avoid `??` when the true branch starts with a prefix nullable `?`.
if matches!(&self.true_type, TSType::JSDocNullableType(t) if !t.postfix) {
p.print_hard_space();
} else {
p.print_soft_space();
}
self.true_type.print(p, ctx);
p.print_soft_space();
p.print_str(":");
p.print_ascii_byte(b':');
p.print_soft_space();
self.false_type.print(p, ctx);
}
@@ -3376,11 +3452,14 @@ impl Gen for TSMappedType<'_> {
p.print_ascii_byte(b'{');
p.print_soft_space();
match self.readonly {
Some(TSMappedTypeModifierOperator::True) => p.print_str("readonly "),
Some(TSMappedTypeModifierOperator::Plus) => p.print_str("+readonly "),
Some(TSMappedTypeModifierOperator::Minus) => p.print_str("-readonly "),
Some(TSMappedTypeModifierOperator::True) => p.print_str("readonly"),
Some(TSMappedTypeModifierOperator::Plus) => p.print_str("+readonly"),
Some(TSMappedTypeModifierOperator::Minus) => p.print_str("-readonly"),
None => {}
}
if self.readonly.is_some() {
p.print_soft_space();
}
p.print_ascii_byte(b'[');
self.key.print(p, ctx);
p.print_str(" in ");
@@ -3417,20 +3496,22 @@ impl Gen for TSQualifiedName<'_> {
impl Gen for TSTypeOperator<'_> {
fn r#gen(&self, p: &mut Codegen, ctx: Context) {
p.print_str(self.operator.to_str());
p.print_hard_space();
p.wrap(
matches!(
&self.type_annotation,
TSType::TSUnionType(_)
| TSType::TSIntersectionType(_)
| TSType::TSFunctionType(_)
| TSType::TSConstructorType(_)
| TSType::TSConditionalType(_)
),
|p| {
self.type_annotation.print(p, ctx);
},
let wrap = matches!(
&self.type_annotation,
TSType::TSUnionType(_)
| TSType::TSIntersectionType(_)
| TSType::TSFunctionType(_)
| TSType::TSConstructorType(_)
| TSType::TSConditionalType(_)
);
if wrap || type_starts_with_non_identifier(&self.type_annotation) {
p.print_soft_space();
} else {
p.print_hard_space();
}
p.wrap(wrap, |p| {
self.type_annotation.print(p, ctx);
});
}
}
@@ -3567,7 +3648,12 @@ impl Gen for TSTypeParameter<'_> {
}
self.name.print(p, ctx);
if let Some(constraint) = &self.constraint {
p.print_str(" extends ");
p.print_str(" extends");
if type_starts_with_non_identifier(constraint) {
p.print_soft_space();
} else {
p.print_hard_space();
}
constraint.print(p, ctx);
}
if let Some(default) = &self.default {
@@ -3960,7 +4046,9 @@ impl Gen for TSInterfaceDeclaration<'_> {
type_parameters.print(p, ctx);
}
if !self.extends.is_empty() {
p.print_str(" extends ");
p.print_soft_space();
p.print_space_before_identifier();
p.print_str("extends ");
p.print_list(&self.extends, ctx);
}
p.print_soft_space();
@@ -3996,7 +4084,7 @@ impl Gen for TSEnumDeclaration<'_> {
}
p.print_str("enum ");
self.id.print(p, ctx);
p.print_space_before_identifier();
p.print_soft_space();
self.body.print(p, ctx);
}
}
+10 -1
View File
@@ -847,6 +847,12 @@ impl<'a> Codegen<'a> {
// `prev == UnaryOperator::LogicalNot` which means last byte is ASCII,
// and therefore previous character is 1 byte from end of buffer
&& self.code.peek_nth_byte_back(1) == Some(b'<'))
// `a! == b`: keep the non-null `!` from gluing to `=` as `!=`/`!==`.
|| (prev == un_op_not
&& matches!(
next,
Operator::Binary(BinaryOperator::Equality | BinaryOperator::StrictEquality)
))
{
self.print_hard_space();
}
@@ -866,7 +872,10 @@ impl<'a> Codegen<'a> {
fn print_decorators(&mut self, decorators: &[Decorator<'_>], ctx: Context) {
for decorator in decorators {
decorator.print(self, ctx);
self.print_hard_space();
// Only separate from the following token when the decorator ends in an
// identifier char (`@dec class`); `@dec() class` can be `@dec()class`.
self.print_soft_space();
self.print_space_before_identifier();
}
}
@@ -52,6 +52,12 @@ fn module_decl() {
test_minify("export default 5", "export default 5;");
test_minify("export default foo", "export default foo;");
test_minify("export default function(){}", "export default function(){}");
// `as` next to a string export/import name needs no space.
test_minify("export { foo as \"name\" }", "export{foo as\"name\"};");
test_minify("import { \"y\" as z } from \"m\"", "import{\"y\"as z}from\"m\";");
test_minify("export * as \"ns\" from \"m\"", "export*as\"ns\" from\"m\";");
test_minify("export { foo as bar }", "export{foo as bar};");
}
#[test]
@@ -110,7 +110,7 @@ interface A{a:string;"b":number;"c"():void;}
########## 25
enum A { a, 'b' }
----------
enum A {a,"b"}
enum A{a,"b"}
########## 26
module 'a'
----------
@@ -122,7 +122,7 @@ declare module "a";
########## 28
a = x!;
----------
a=x! ;
a=x!;
########## 29
b = (x as y);
----------
@@ -142,7 +142,7 @@ d=x satisfies y;
########## 33
export @x declare abstract class C {}
----------
export @x declare abstract class C{}
export@x declare abstract class C{}
########## 34
div<T>``
----------
@@ -170,15 +170,15 @@ export type Component<Props=any,RawBindings=any,D=any,C extends ComputedOptions=
########## 37
(a || b) as any
----------
(a||b) as any;
(a||b)as any;
########## 38
(a ** b) as any
----------
(a**b) as any;
(a**b)as any;
########## 39
(function g() {}) as any
----------
(function g(){}) as any;
(function g(){})as any;
########## 40
import defaultExport from "module-name";
@@ -215,7 +215,7 @@ export { default, /* …, */ } from "module-name";
export { default as name16 } from "module-name";
----------
import defaultExport from"module-name";import*as name from"module-name";import{export1}from"module-name";import{export1 as alias1}from"module-name";import{default as alias}from"module-name";import{export1,export2}from"module-name";import{export1,export2 as alias2}from"module-name";import{"string name" as alias}from"module-name";import defaultExport,{export1}from"module-name";import defaultExport,*as name from"module-name";import"module-name";import{}from"mod";export let name1,name2;export const name3=1,name4=2;export function functionName(){}export class ClassName{}export function*generatorFunctionName(){}export const{name5,name2:bar}=o;export const[name6,name7]=array;export{name8,name81};export{variable1 as name9,variable2 as name10,name82};export{variable1 as "string name"};export{name1 as default1};export*from"module-name";export*as name11 from"module-name";export{name12,nameN}from"module-name";export{import1 as name13,import2 as name14,name15}from"module-name";export{default}from"module-name";export{default as name16}from"module-name";
import defaultExport from"module-name";import*as name from"module-name";import{export1}from"module-name";import{export1 as alias1}from"module-name";import{default as alias}from"module-name";import{export1,export2}from"module-name";import{export1,export2 as alias2}from"module-name";import{"string name"as alias}from"module-name";import defaultExport,{export1}from"module-name";import defaultExport,*as name from"module-name";import"module-name";import{}from"mod";export let name1,name2;export const name3=1,name4=2;export function functionName(){}export class ClassName{}export function*generatorFunctionName(){}export const{name5,name2:bar}=o;export const[name6,name7]=array;export{name8,name81};export{variable1 as name9,variable2 as name10,name82};export{variable1 as"string name"};export{name1 as default1};export*from"module-name";export*as name11 from"module-name";export{name12,nameN}from"module-name";export{import1 as name13,import2 as name14,name15}from"module-name";export{default}from"module-name";export{default as name16}from"module-name";
########## 41
import a = require("a");
+124 -1
View File
@@ -214,7 +214,7 @@ fn minify_ts_type_space() {
};
// Conditional type: `?`/`:` are tight like a JS conditional expression.
min("type T = A extends B ? C : D;", "type T=A extends B?C:D;");
min("type T = A extends {} ? B : C;", "type T=A extends {}?B:C;");
min("type T = A extends {} ? B : C;", "type T=A extends{}?B:C;");
// Constructor type: no space after `new` before `(`/`<`.
min("type N = new () => Foo;", "type N=new()=>Foo;");
min("type N = abstract new (x: number) => Foo;", "type N=abstract new(x:number)=>Foo;");
@@ -223,6 +223,129 @@ fn minify_ts_type_space() {
min("type T = A extends C? ? D : E;", "type T=A extends C? ?D:E;");
}
#[test]
fn minify_decorator_space() {
let min = |src: &str, expected: &str| {
test_options_with_source_type(src, expected, SourceType::ts(), CodegenOptions::minify());
};
// `@dec class` keeps a space (decorator ends in an identifier char), but
// `@dec() class` does not (`)` is not an identifier char).
min("@dec class C {}", "@dec class C{}");
min("@dec() class C {}", "@dec()class C{}");
min("@a.b @c class C {}", "@a.b @c class C{}");
// `export` before a decorated class needs no space (`@` is not an identifier char).
min("export @dec class C {}", "export@dec class C{}");
min("export @dec() class C {}", "export@dec()class C{}");
// `accessor` before a private name needs no space.
min("class C { accessor #x = 1 }", "class C{accessor#x=1}");
min("class C { accessor x = 1 }", "class C{accessor x=1}");
}
#[test]
fn minify_ts_operator_space() {
let min = |src: &str, expected: &str| {
test_options_with_source_type(src, expected, SourceType::ts(), CodegenOptions::minify());
};
// `as`/`satisfies` need no leading space after a non-identifier char.
min("x = foo() as Bar;", "x=foo()as Bar;");
min("x = arr[0] as Bar;", "x=arr[0]as Bar;");
min("x = `s` as const;", "x=`s`as const;");
min("x = foo() satisfies Bar;", "x=foo()satisfies Bar;");
// ...but a space is required after an identifier.
min("x = y as Bar;", "x=y as Bar;");
min("x = y satisfies Bar;", "x=y satisfies Bar;");
// No trailing space when the target type starts with punctuation.
min("x = y as { a: 1 };", "x=y as{a:1;};");
min("x = y as (a: number) => void;", "x=y as(a:number)=>void;");
min("x = y satisfies [number];", "x=y satisfies[number];");
// A non-null `!` needs no trailing space before a member, statement end, or call.
min("x = a!.b;", "x=a!.b;");
min("a!;", "a!;");
min("x = a!();", "x=a!();");
// ...but must keep one before `=`/`==`/`===` so it does not become `!=`/`!==`.
min("a! = b;", "a! =b;");
min("x = a! == b;", "x=a! ==b;");
min("x = a! === b;", "x=a! ===b;");
// `!=`/`!==` do not retokenize, so no space is needed.
min("x = a! != b;", "x=a!!=b;");
// `enum` body brace needs no leading space.
min("enum E { A, B }", "enum E{A,B}");
min("const enum F { X = 1 }", "const enum F{X=1}");
}
#[test]
fn minify_conditional_type_space() {
let min = |src: &str, expected: &str| {
test_options_with_source_type(src, expected, SourceType::ts(), CodegenOptions::minify());
};
// `extends`/`?`/`:` need no surrounding space next to non-identifier tokens.
min("type A<T> = T extends string ? 1 : 2;", "type A<T>=T extends string?1:2;");
min("type B<T> = Foo<T> extends string ? X : Y;", "type B<T>=Foo<T>extends string?X:Y;");
min("type C<T> = T[K] extends string ? X : Y;", "type C<T>=T[K]extends string?X:Y;");
// A prefix JSDoc nullable true branch must keep the `? ` separator (no `??`).
min("type D<T> = T extends B ? ?C : D;", "type D<T>=T extends B? ?C:D;");
// `extends`/`keyof` need no trailing space before a punctuation-starting type.
min("type E<T> = T extends [infer U] ? U : never;", "type E<T>=T extends[infer U]?U:never;");
min("type F<T> = T extends { a: 1 } ? 1 : 2;", "type F<T>=T extends{a:1;}?1:2;");
min("type G = keyof [a, b];", "type G=keyof[a,b];");
// ...but keep it before an identifier-starting type.
min("type H<T> = T extends Base ? 1 : 2;", "type H<T>=T extends Base?1:2;");
// `readonly`/`extends` before a parenthesized array element need no trailing space.
min("type I<T> = readonly (keyof T)[];", "type I<T>=readonly(keyof T)[];");
min(
"type J<T> = T extends (infer A)[] ? A : never;",
"type J<T>=T extends(infer A)[]?A:never;",
);
min("type K = readonly string[];", "type K=readonly string[];");
// `new` constructor type needs no space before `(`/`<`.
min("type L = new () => Foo;", "type L=new()=>Foo;");
min("type M = abstract new (x: number) => Bar;", "type M=abstract new(x:number)=>Bar;");
// A type-parameter constraint needs no trailing space before a punctuation-starting type.
min("type N<T extends (...a: any) => any> = T;", "type N<T extends(...a:any)=>any>=T;");
min("type O<T extends [a, b]> = T;", "type O<T extends[a,b]>=T;");
min("type P<T extends Base> = T;", "type P<T extends Base>=T;");
// `as`/`satisfies` before a union/intersection whose first member starts with punctuation.
min("x = res as { a: 1 } | undefined;", "x=res as{a:1;}|undefined;");
min("x = res as Foo | Bar;", "x=res as Foo|Bar;");
}
#[test]
fn minify_export_type_star() {
let min = |src: &str, expected: &str| {
test_options_with_source_type(src, expected, SourceType::ts(), CodegenOptions::minify());
};
min("export type * from \"m\";", "export type*from\"m\";");
min("export * from \"m\";", "export*from\"m\";");
min("export type * as ns from \"m\";", "export type*as ns from\"m\";");
}
#[test]
fn minify_heritage_and_mapped_space() {
let min = |src: &str, expected: &str| {
test_options_with_source_type(src, expected, SourceType::ts(), CodegenOptions::minify());
};
// Heritage `extends`/`implements` need no leading space after type-argument `>`.
min(
"class A<T> extends Base<T> implements I, J {}",
"class A<T>extends Base<T>implements I,J{}",
);
min("class B extends Base implements I {}", "class B extends Base implements I{}");
min(
"interface I<T = string> extends Omit<A, B>, C {}",
"interface I<T=string>extends Omit<A,B>,C{}",
);
min("interface J extends K {}", "interface J extends K{}");
// Mapped-type `readonly` needs no trailing space before `[`.
min(
"type M<T> = { readonly [P in keyof T]: T[P] };",
"type M<T>={readonly[P in keyof T]:T[P]};",
);
min(
"type N<T> = { -readonly [P in keyof T]-?: T[P] };",
"type N<T>={-readonly[P in keyof T]-?:T[P]};",
);
}
#[test]
fn ts_as_expression_in_binary_expr() {
test_idempotency("key in (that as object)");