Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Inline *_base declaration in d.ts files #59550

Open
6 tasks done
CraigMacomber opened this issue Aug 7, 2024 · 4 comments Β· May be fixed by #61087
Open
6 tasks done

Inline *_base declaration in d.ts files #59550

CraigMacomber opened this issue Aug 7, 2024 · 4 comments Β· May be fixed by #61087
Labels
Experimentation Needed Someone needs to try this out to see what happens Suggestion An idea for TypeScript
Milestone

Comments

@CraigMacomber
Copy link

CraigMacomber commented Aug 7, 2024

πŸ” Search Terms

Anonymous class, _base, base, d.ts, api-extractor, recursive

βœ… Viability Checklist

⭐ Suggestion

Currently classes which extends anonomous classes compile to d.ts files with two declarations:

class A extends class {} {}

Generates:

declare const A_base: {
    new (): {};
};
declare class A extends A_base {
}

playground link

My suggestion is for it to generate something like this instead:

declare class A extends ({} as {
    new (): {};
}) {
}

More generally replace:

declare const A_base: DEFINITION_OF_BASE_HERE;
declare class A extends A_base {
}

with:

declare class A extends ({} as DEFINITION_OF_BASE_HERE) {
}

πŸ“ƒ Motivating Example

When exporting classes with anonymous bases (for example classes with bases generated using functions, for example in fluid-framework's tree schema system, it would be nice if the generated d.ts file better matched the original source, so tools (like TypeScript and API-Extractor) behave more similarly to how they would if run on the source instead of on the d.ts. This would make the d.ts better serve as a concise summary of the type information of the original code.

πŸ’» Use Cases

  1. What do you want to use this for?
    I know of two cases this would help:

    1. API-Extractor: it would fix [api-extractor] Extending anonymous classes errors that _base class inserted by compiler is not exported.Β rushstack#4429 by removing this odd case from d.ts files.
    2. Some recursive types compile without error when the base in inline, but not when its split into a separate variable. Making the d.ts declare it inline when the original source files does helps make the d.ts more aligned with the non-d.ts type checking for recursive types. I haven't extracted a minimal repro for this, but there are some not so minimal examples (and a workaround) in tree: Add Recursive ArrayNode export workaroundΒ FluidFramework#22122.
  2. What shortcomings exist with current approaches?
    Currently the behavior is confusing since most TS developers don't think about the differences between the d.ts files and the original source, so having to do workarounds to make them stay aligned (like manually exporting the base to make API-Extractor happy, or ensuring that if the base is not inline, that the code still type checked for the recursive case) is confusing and unintuitive.

  3. What workarounds are you using in the meantime?
    For API extractor, manually export the base type under a different name.
    For the recursive type issue, export carefully crafted usage before the declaration which happens to cause it to compile with the split declaration.

@CraigMacomber
Copy link
Author

I hit this again. It makes it really annoying for users of Fluid Framework's SharedTree to use API-Extractor since doing so requires really annoying workarounds to export schema.

A schema definition like export class JsonArray extends sf.arrayRecursive("array", JsonUnion) {} has to become

/**
 * Do not use. Exists only as a workaround for {@link https://github.com/microsoft/TypeScript/issues/59550} and {@link https://github.com/microsoft/rushstack/issues/4429}.
 * @system @alpha
 */
export const _APIExtractorWorkaroundJsonArrayBase = sf.arrayRecursive("array", JsonUnion);

/**
 * Docs here....
 * @alpha
 */
export class JsonArray extends _APIExtractorWorkaroundJsonArrayBase {}

Having to do this for every shared tree schema makes a real mess schema files and the package API surface.
It also exposes customers of SharedTree and API-Extractor to an implementation detail of d.ts generation which they really shouldn't have to know, but if they don't know, they will have a hard time knowing what to do about an error like (ae-forgotten-export) The symbol "JsonArray_base" needs to be exported by the entry point alpha.d.ts when that's a symbol they never defined. THis is annoying for me who already knows exactly that's going on and how to work around it: I imagine for someone with no idea about the _base types in d.ts files, it would be quite confusing.

@octogonz
Copy link

octogonz commented Nov 8, 2024

Also, in general it is difficult to detect these _base declarations, as they can have other names such as _base_1. For example:

example.ts

class A_base {}
class A extends class {} {}

example.d.ts

declare class A_base {
}
declare const A_base_1: {
    new (): {};
};
declare class A extends A_base_1 {
}

The fundamental challenge is that, by design, API Extractor processes .d.ts files and does not consider their .ts inputs. I agree that, this case, emitting an algebraic type might make the .d.ts file actually easier to interpret.

@CraigMacomber
Copy link
Author

I hit this again. I'd love to see my suggested fix implemented: I'm not aware of any downsides to it.

I think the code that needs to change is

if (extendsClause && !isEntityNameExpression(extendsClause.expression) && extendsClause.expression.kind !== SyntaxKind.NullKeyword) {
// We must add a temporary declaration for the extends clause expression
const oldId = input.name ? unescapeLeadingUnderscores(input.name.escapedText) : "default";
const newId = factory.createUniqueName(`${oldId}_base`, GeneratedIdentifierFlags.Optimistic);
getSymbolAccessibilityDiagnostic = () => ({
diagnosticMessage: Diagnostics.extends_clause_of_exported_class_0_has_or_is_using_private_name_1,
errorNode: extendsClause,
typeName: input.name,
});
const varDecl = factory.createVariableDeclaration(newId, /*exclamationToken*/ undefined, resolver.createTypeOfExpression(extendsClause.expression, input, declarationEmitNodeBuilderFlags, declarationEmitInternalNodeBuilderFlags, symbolTracker), /*initializer*/ undefined);
const statement = factory.createVariableStatement(needsDeclare ? [factory.createModifier(SyntaxKind.DeclareKeyword)] : [], factory.createVariableDeclarationList([varDecl], NodeFlags.Const));
const heritageClauses = factory.createNodeArray(map(input.heritageClauses, clause => {
if (clause.token === SyntaxKind.ExtendsKeyword) {
const oldDiag = getSymbolAccessibilityDiagnostic;
getSymbolAccessibilityDiagnostic = createGetSymbolAccessibilityDiagnosticForNode(clause.types[0]);
const newClause = factory.updateHeritageClause(clause, map(clause.types, t => factory.updateExpressionWithTypeArguments(t, newId, visitNodes(t.typeArguments, visitDeclarationSubtree, isTypeNode))));
getSymbolAccessibilityDiagnostic = oldDiag;
return newClause;
}
return factory.updateHeritageClause(clause, visitNodes(factory.createNodeArray(filter(clause.types, t => isEntityNameExpression(t.expression) || t.expression.kind === SyntaxKind.NullKeyword)), visitDeclarationSubtree, isExpressionWithTypeArguments));
}));
return [
statement,
cleanup(factory.updateClassDeclaration(
input,
modifiers,
input.name,
typeParameters,
heritageClauses,
members,
))!,
]; // TODO: GH#18217
}
else {
const heritageClauses = transformHeritageClauses(input.heritageClauses);
return cleanup(factory.updateClassDeclaration(
input,
modifiers,
input.name,
typeParameters,
heritageClauses,
members,
));
}

I suspect that entire if/else could be removed and use my suggested format in both cases, or just replace the

// We must add a temporary declaration for the extends clause expression
const oldId = input.name ? unescapeLeadingUnderscores(input.name.escapedText) : "default";
const newId = factory.createUniqueName(`${oldId}_base`, GeneratedIdentifierFlags.Optimistic);
getSymbolAccessibilityDiagnostic = () => ({
diagnosticMessage: Diagnostics.extends_clause_of_exported_class_0_has_or_is_using_private_name_1,
errorNode: extendsClause,
typeName: input.name,
});
const varDecl = factory.createVariableDeclaration(newId, /*exclamationToken*/ undefined, resolver.createTypeOfExpression(extendsClause.expression, input, declarationEmitNodeBuilderFlags, declarationEmitInternalNodeBuilderFlags, symbolTracker), /*initializer*/ undefined);
const statement = factory.createVariableStatement(needsDeclare ? [factory.createModifier(SyntaxKind.DeclareKeyword)] : [], factory.createVariableDeclarationList([varDecl], NodeFlags.Const));
const heritageClauses = factory.createNodeArray(map(input.heritageClauses, clause => {
if (clause.token === SyntaxKind.ExtendsKeyword) {
const oldDiag = getSymbolAccessibilityDiagnostic;
getSymbolAccessibilityDiagnostic = createGetSymbolAccessibilityDiagnosticForNode(clause.types[0]);
const newClause = factory.updateHeritageClause(clause, map(clause.types, t => factory.updateExpressionWithTypeArguments(t, newId, visitNodes(t.typeArguments, visitDeclarationSubtree, isTypeNode))));
getSymbolAccessibilityDiagnostic = oldDiag;
return newClause;
}
return factory.updateHeritageClause(clause, visitNodes(factory.createNodeArray(filter(clause.types, t => isEntityNameExpression(t.expression) || t.expression.kind === SyntaxKind.NullKeyword)), visitDeclarationSubtree, isExpressionWithTypeArguments));
}));
return [
statement,
cleanup(factory.updateClassDeclaration(
input,
modifiers,
input.name,
typeParameters,
heritageClauses,
members,
))!,
]; // TODO: GH#18217
segment with simplified logic using my suggested format.

@RyanCavanaugh RyanCavanaugh added Experimentation Needed Someone needs to try this out to see what happens and removed Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature labels Jan 30, 2025
@RyanCavanaugh RyanCavanaugh added this to the Backlog milestone Jan 30, 2025
@CraigMacomber CraigMacomber linked a pull request Jan 31, 2025 that will close this issue
@CraigMacomber
Copy link
Author

I have created #61087 with an implementation of this change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Experimentation Needed Someone needs to try this out to see what happens Suggestion An idea for TypeScript
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants