Skip to content

Commit 3322f9e

Browse files
rix0rrrgithub-actions
andauthored
fix: omitting optional property fails C# compilation (#1448)
The following is a minimal reproducing example: ```ts export interface ISomeInterface { readonly xyz?: string; } export abstract class SomeClass implements ISomeInterface { } ``` This breaks code generation for C#, as the `SomeClass._Proxy` class we generate will try to generate an `override` for the `Xyz` property, which is missing from the `SomeClass` class itself. This issue is exhibited by a difference in logic between how we iterate the members of a class when we generate the class itself vs when we generate its proxy. However, after looking at the code I'm not convinced it's correct either way (nor for Java), so to be safe we picked the solution where we force the user to declare the property on the class. The correct declaration would be: ```ts export abstract class SomeClass implements ISomeInterface { public abstract readonly xyz?: string; } ``` Related: https://github.com/aws/aws-cdk/pull/31946/files --- By submitting this pull request, I confirm that my contribution is made under the terms of the [Apache 2.0 license]. [Apache 2.0 license]: https://www.apache.org/licenses/LICENSE-2.0 --------- Signed-off-by: github-actions <github-actions@github.com> Co-authored-by: github-actions <github-actions@github.com>
1 parent b123a06 commit 3322f9e

File tree

4 files changed

+120
-15
lines changed

4 files changed

+120
-15
lines changed

src/jsii-diagnostic.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,13 @@ export class JsiiDiagnostic implements ts.Diagnostic {
628628
name: 'language-compatibility/static-member-name-conflicts-with-nested-type',
629629
});
630630

631+
public static readonly JSII_5021_ABSTRACT_CLASS_MISSING_PROP_IMPL = Code.error({
632+
code: 5021,
633+
formatter: (intf: spec.InterfaceType, cls: spec.ClassType, prop: string) =>
634+
`A declaration of "${intf.name}.${prop}" is missing on class "${cls.name}". Declare the property as "public abstract" if you want to defer it to subclasses.`,
635+
name: 'language-compatibility/abstract-class-missing-prop-impl',
636+
});
637+
631638
//////////////////////////////////////////////////////////////////////////////
632639
// 6000 => 6999 -- RESERVED
633640

src/validator.ts

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import * as assert from 'node:assert';
22
import * as spec from '@jsii/spec';
33
import * as deepEqual from 'fast-deep-equal';
44
import * as ts from 'typescript';
5-
65
import * as Case from './case';
76
import { Emitter } from './emitter';
87
import { JsiiDiagnostic } from './jsii-diagnostic';
@@ -41,6 +40,7 @@ function _defaultValidations(): ValidationFunction[] {
4140
_allTypeReferencesAreValid,
4241
_inehritanceDoesNotChangeContracts,
4342
_staticMembersAndNestedTypesMustNotSharePascalCaseName,
43+
_abstractClassesMustImplementAllProperties,
4444
];
4545

4646
function _enumMembersMustUserUpperSnakeCase(_: Validator, assembly: spec.Assembly, diagnostic: DiagnosticEmitter) {
@@ -382,7 +382,7 @@ function _defaultValidations(): ValidationFunction[] {
382382
expectedNode?.modifiers?.find(
383383
(mod) => mod.kind === ts.SyntaxKind.PublicKeyword || mod.kind === ts.SyntaxKind.ProtectedKeyword,
384384
) ?? declarationName(expectedNode),
385-
'The implemented delcaration is here.',
385+
'The implemented declaration is here.',
386386
),
387387
);
388388
}
@@ -396,7 +396,7 @@ function _defaultValidations(): ValidationFunction[] {
396396
expected.type,
397397
).maybeAddRelatedInformation(
398398
expectedNode?.type ?? declarationName(expectedNode),
399-
'The implemented delcaration is here.',
399+
'The implemented declaration is here.',
400400
),
401401
);
402402
}
@@ -412,7 +412,7 @@ function _defaultValidations(): ValidationFunction[] {
412412
).maybeAddRelatedInformation(
413413
expectedNode?.modifiers?.find((mod) => mod.kind === ts.SyntaxKind.ReadonlyKeyword) ??
414414
declarationName(expectedNode),
415-
'The implemented delcaration is here.',
415+
'The implemented declaration is here.',
416416
),
417417
);
418418
}
@@ -426,13 +426,90 @@ function _defaultValidations(): ValidationFunction[] {
426426
expected.optional,
427427
).maybeAddRelatedInformation(
428428
expectedNode?.questionToken ?? expectedNode?.type ?? declarationName(expectedNode),
429-
'The implemented delcaration is here.',
429+
'The implemented declaration is here.',
430430
),
431431
);
432432
}
433433
}
434434
}
435435

436+
/**
437+
* Abstract classes that implement an interface should have a declaration for every member.
438+
*
439+
* For non-optional members, TypeScript already enforces this. This leaves the user the
440+
* ability to forget optional properties (`readonly prop?: string`).
441+
*
442+
* At least our codegen for this case fails in C#, and I'm not convinced it does the right
443+
* thing in Java either. So we will disallow this, and require users to declare these
444+
* fields on the class. It can always be `public abstract readonly prop?: string` if they
445+
* don't want to give an implementation yet.
446+
*/
447+
function _abstractClassesMustImplementAllProperties(
448+
validator: Validator,
449+
assembly: spec.Assembly,
450+
diagnostic: DiagnosticEmitter,
451+
) {
452+
for (const type of _allTypes(assembly)) {
453+
if (!spec.isClassType(type) || !type.abstract) {
454+
continue;
455+
}
456+
457+
const classProps = collectClassProps(type, new Set());
458+
459+
for (const implFqn of type.interfaces ?? []) {
460+
checkInterfacePropsImplemented(implFqn, type, classProps);
461+
}
462+
}
463+
464+
/**
465+
* Return all property names declared on this class and its base classes
466+
*/
467+
function collectClassProps(type: spec.ClassType, into: Set<string>): Set<string> {
468+
for (const prop of type.properties ?? []) {
469+
into.add(prop.name);
470+
}
471+
472+
if (type.base) {
473+
const base = _dereference(type.base, assembly, validator);
474+
if (spec.isClassType(base)) {
475+
collectClassProps(base, into);
476+
}
477+
}
478+
479+
return into;
480+
}
481+
482+
function checkInterfacePropsImplemented(interfaceFqn: string, cls: spec.ClassType, propNames: Set<string>) {
483+
const intf = _dereference(interfaceFqn, assembly, validator);
484+
if (!spec.isInterfaceType(intf)) {
485+
return;
486+
}
487+
488+
// We only have to check for optional properties, because anything required
489+
// would have been caught by the TypeScript compiler already.
490+
for (const prop of intf.properties ?? []) {
491+
if (!prop.optional) {
492+
continue;
493+
}
494+
495+
if (!propNames.has(prop.name)) {
496+
diagnostic(
497+
JsiiDiagnostic.JSII_5021_ABSTRACT_CLASS_MISSING_PROP_IMPL.create(
498+
bindings.getClassOrInterfaceRelatedNode(cls),
499+
intf,
500+
cls,
501+
prop.name,
502+
).maybeAddRelatedInformation(bindings.getPropertyRelatedNode(prop), 'The implemented declaration is here.'),
503+
);
504+
}
505+
}
506+
507+
for (const extFqn of intf.interfaces ?? []) {
508+
checkInterfacePropsImplemented(extFqn, cls, propNames);
509+
}
510+
}
511+
}
512+
436513
function _staticMembersAndNestedTypesMustNotSharePascalCaseName(
437514
_: Validator,
438515
assembly: spec.Assembly,

test/__snapshots__/negatives.test.ts.snap

Lines changed: 25 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export interface ISomeInterface {
2+
readonly xyz?: string;
3+
}
4+
5+
export abstract class SomeClass implements ISomeInterface {
6+
}

0 commit comments

Comments
 (0)