fix(isolated_declarations): drop required type check for private parameter properties on private constructors (#21515)

## Summary

A private parameter property on a private constructor doesn't need an explicit type annotation:

- The constructor signature is collapsed to `private constructor();` — the parameter is never emitted.
- The parameter-property class member is emitted as `private readonly name;` — type-erased.

Since the parameter's type does not appear in the `.d.ts` output in either place, no type is needed. This matches `tsc --isolatedDeclarations`. Non-private parameter properties on a private constructor still require a type, because the class member keeps the type.

## Example

```ts
export class GreyhoundServer {
  private constructor(
    private readonly logger = new ConsoleLogger(['greyhound-server']),
  ) {}
}
```

Before: `TS9011: Parameter must have an explicit type annotation`.
After: no error — emits `private readonly logger;` and `private constructor();`, matching tsc.

## Test plan

- [x] Added snapshot fixtures covering both the no-error case (private param property) and the still-errors case (public param property on private constructor)
- [x] `cargo test -p oxc_isolated_declarations`

Closes #21503 (case 1)
This commit is contained in:
Dunqing committed 2026-04-20 08:33:55 +00:00
1 parent c1eeae3e12
commit 48967e8325
3 files changed
+73 -5

No files matched your search

@@ -41,6 +41,7 @@ impl<'a> IsolatedDeclarations<'a> {
&self,
param: &FormalParameter<'a>,
is_remaining_params_have_required: bool,
in_private_constructor: bool,
) -> Option<FormalParameter<'a>> {
let pattern = &param.pattern;
if param.initializer.is_some()
@@ -69,9 +70,14 @@ impl<'a> IsolatedDeclarations<'a> {
.as_ref()
.map(|type_annotation| type_annotation.type_annotation.clone_in(self.ast.allocator))
.or_else(|| {
// report error for has no type annotation
let new_type = self.infer_type_from_formal_parameter(param);
if new_type.is_none() {
// A private parameter property on a private constructor needs no
// explicit type: the constructor signature is collapsed to
// `private constructor();` and the class member is emitted as
// `private readonly name;` with no type annotation.
let is_elided_private_param = in_private_constructor
&& param.accessibility.is_some_and(TSAccessibility::is_private);
if new_type.is_none() && !is_elided_private_param {
self.error(parameter_must_have_explicit_type(param.span));
}
new_type
@@ -132,7 +138,7 @@ impl<'a> IsolatedDeclarations<'a> {
pub(crate) fn transform_formal_parameters(
&self,
params: &FormalParameters<'a>,
skip_no_accessibility_param: bool,
in_private_constructor: bool,
) -> ArenaBox<'a, FormalParameters<'a>> {
if params.kind.is_signature() || (params.rest.is_none() && params.items.is_empty()) {
return self.ast.alloc(params.clone_in(self.ast.allocator));
@@ -143,14 +149,18 @@ impl<'a> IsolatedDeclarations<'a> {
.items
.iter()
.enumerate()
.filter(|(_, item)| !skip_no_accessibility_param || item.has_modifier())
.filter(|(_, item)| !in_private_constructor || item.has_modifier())
.filter_map(|(index, item)| {
let is_remaining_params_have_required = params
.items
.iter()
.skip(index)
.any(|item| !(item.optional || item.initializer.is_some()));
self.transform_formal_parameter(item, is_remaining_params_have_required)
self.transform_formal_parameter(
item,
is_remaining_params_have_required,
in_private_constructor,
)
}),
);
@@ -174,3 +174,30 @@ export class AccessorPairWithProtectedPrivateSetter {
private set value(v: number) {}
}
// Private parameter properties on a private constructor need no explicit type:
// the constructor signature is collapsed and the class member is type-erased.
class InferredDefault {}
export class PrivateConstructorWithPrivateParamPropertyInferredDefault {
private constructor(
private readonly a = new InferredDefault(),
private b = new InferredDefault(),
) {}
}
// Non-private parameter properties on a private constructor still need a type,
// because the class member is still emitted.
export class PrivateConstructorWithPublicParamPropertyInferredDefault {
private constructor(
public readonly a = new InferredDefault(),
) {}
}
// The suppression must be limited to `private` constructors — a `protected`
// constructor keeps its signature, so a private parameter property with an
// uninferrable default still requires an explicit type.
export class ProtectedConstructorWithPrivateParamPropertyInferredDefault {
protected constructor(
private readonly a = new InferredDefault(),
) {}
}
@@ -128,6 +128,19 @@ export declare class AccessorPairWithProtectedPrivateSetter {
protected get value(): number;
private set value(value);
}
export declare class PrivateConstructorWithPrivateParamPropertyInferredDefault {
private readonly a;
private b;
private constructor();
}
export declare class PrivateConstructorWithPublicParamPropertyInferredDefault {
readonly a;
private constructor();
}
export declare class ProtectedConstructorWithPrivateParamPropertyInferredDefault {
private readonly a;
protected constructor(a?);
}
==================== Errors ====================
@@ -150,5 +163,23 @@ export declare class AccessorPairWithProtectedPrivateSetter {
80 | public get badGetter() {
`----
x TS9011: Parameter must have an explicit type annotation with
| --isolatedDeclarations.
,-[192:3]
191 | private constructor(
192 | public readonly a = new InferredDefault(),
: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
193 | ) {}
`----
x TS9011: Parameter must have an explicit type annotation with
| --isolatedDeclarations.
,-[201:3]
200 | protected constructor(
201 | private readonly a = new InferredDefault(),
: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
202 | ) {}
`----
```