Skip to content

[Blazor] Adds support for specifying generic type constraints in Razor files #31800

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

Merged
merged 11 commits into from
Apr 16, 2021

Conversation

javiercn
Copy link
Member

@javiercn javiercn commented Apr 14, 2021

This is a work in progress.

However I appreciate feedback from the appropriate folks.

Also note that I haven't tested this E2E nor done any polishing.

I'm looking to check if the approach is sound and what might be missing before this is "complete"

Fixes #8433

@ghost ghost added the area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates label Apr 14, 2021
@javiercn javiercn requested review from captainsafia, SteveSandersonMS and a team April 14, 2021 13:33
@javiercn javiercn added area-blazor Includes: Blazor, Razor Components and removed area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates labels Apr 14, 2021
@javiercn javiercn marked this pull request as ready for review April 15, 2021 13:05
@javiercn
Copy link
Member Author

This is now ready for review

@javiercn
Copy link
Member Author

javiercn commented Apr 15, 2021

Summary

We are adding support for specifying generic type constraints in the @typeparam directive. The constraints are specified after the identifier for the type parameter using the same syntax used in c#. For example:

@typeparam TParameter where TParameter : Base, ISomeInterface, class, new()

Motivation

Customers are very vocal in our repository about wanting this feature. There is currently an alternative way to constrain a generic type defined in a razor file which is to create a partial class and apply the constraints there.

Goals

  • Support specifying a list of constraints for a generic type parameter on the same line where it is defined
  • Errors should be displayed on the definition in the razor file for invalid constraint combinations.
  • Intellisense should be available while the user is typing the constraint on the editor.

Non-goals

  • Support defining constraints for a generic type across multiple lines.

Scenarios

  • As a customer I want to constraint a generic type parameter for my component.

Intellisense and colorization

Intellisense and colorization

Error experience

Errors produced

Errors compared to similar directives

Error compared to similar directives

Semantic errors

Showing a semantic error

Risks

  • Increases the compiler complexity.
  • Might introduce parsing errors that block valid applications from working.
  • Intellisense is provided at design time via a mapped method, which has the potential to lead to slightly different semantics in corner cases.
    • This is purely hypothetical at the moment.

Interaction with other parts of the framework

  • Can potentially interfere with partial classes defined by the customer

Detailed design

This is implemented by a compiler feature.

To begin with, we have introduced a new DirectiveTokenType called GenericTypeConstraint that represents all the code between the where keyword and the end of the line.

We update the typeparam directive to optionally accept a GenericTypeConstraint directive token type to support associating constraints to the type.

During parsing we process the GenericTypeConstraint and check that:

  • It starts with the where keyword.
  • The identifier matches the identifier before the where keyword.

We consume all the tokens until the end of the line and create the directive.

During the classification of the document we capture the constraint in addition to the generic type parameter name. In this context the constraint is the entire line from the where keyword in the directive to the end of the line.

When we generate the code, we pass the type as well as the constraint and after we've written the class name, base class, and implemented interfaces we add each constraint to the document in a new line. For example

public class MyCompontent<TData> : ComponentBase
where TData : class
{
  ...
}

The other interesting part of the equation is the generated design time code. In order to provide a proper error experience and intellisense, in design time builds we generate a unique method that we map to the line where the constraint is defined on the razor file. For the generated code above, the design time equivalent looks roughly like this:

public class MyCompontent<TData> : ComponentBase
where TData : class
{
  Action __RazorDesignTimeHelpers = () => 
  {
    Action __constraint1 = () => 
    {
        # pragma warning disable CS...// Generic type parameter hides class type parameter
        # pragma warning disable CS...// Method not uses
        # line 1 "<<path-to-razor-file.razor>>"
        void __TypeParameter_TData<TDAta>() where TData : class
        # pragma warning restore CS...
        # pragma warning restore CS ..
    }
  }
}

We generate a generic local function per constrained parameter with a generic type parameter of the same name and associate the definition with the one in the app.

Drawbacks

  • Intellisense, errors, coloring, etc will always be "slightly" worse compared to the same C# code due to the necessary mappings we are forced to implement.

Considered alternatives

  • There might be other implementation approaches we could follow here:
    • Update the compiler to support adding #pragma line during the class declaration, which would avoid the need to generate the generic method for the constraint at design time.
    • Generate a single private nested class or a separate internal class at design time instead of the method. In that case we could generate a class that contains all the constraints and the semantics would be equivalent to the constraints defined by the class. For example:
    # pragma warning disable CS...// Generic type parameter hides class type parameter
    private class Class_TypeConstraints<TKey,TValue>
    # line 1 "<<path-to-razor-file.razor>>"
    where TKey : class
    # line 2 "<<path-to-razor-file.razor>>"
    where TValue : struct
    {}
    • For the initial implementation we've chosen to use the method because it simplifies things and we don't anticipate any major drawback, if we discover an important issue we can always switch our approach.
    • Even in that situation it will only affect design time builds and likely in the presence of multiple generic type parameters.

Open questions

  • Do we need an additional DirectiveTokenType to represent generic type constraint lists or can this be part of a member token
    • The assumption is that it should be a separate token type; otherwise any position where we accept a member token could suddenly accept type parameter constraints, which might not be correct.


public static RazorProjectEngineBuilder Register(RazorProjectEngineBuilder builder)
public static RazorProjectEngineBuilder Register(RazorProjectEngineBuilder builder, bool supportConstraints)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of passing this boolean through, should we just create a new ComponentTypeParamWithGeneric directive that we then conditionally register in the RazorEngineBuilder.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if I'm understanding correctly.

The directives map 1 to 1 to language concepts, so it's not clear to me that is desirable to have two of them to represent the same concept. The problem with it is that we would need to litter the codebase with additional checks for the newly introduced directive, when the only difference is what is allowed per language version.

I think it makes more sense for all that to be configured one time in one place since it only varies by language version.

@@ -1327,14 +1337,16 @@ private void ParseExtensibleDirective(in SyntaxListBuilder<RazorSyntaxNode> buil
{
AcceptWhile(IsSpacingTokenIncludingComments);

SpanContext.ChunkGenerator = SpanChunkGenerator.Null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good on spotting this!

AcceptAndMoveNext();
AcceptWhile(SyntaxKind.Whitespace);
// Check that the type name matches the type name before the where clause.
// Find a better way to do this
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if a user does something like @typeparam where without providing the parameter name?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is mapped to a generic type parameter with name where and the razor compiler produces an error (which is expected).

Copy link
Contributor

@TanayParikh TanayParikh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't have much context in this area, but LGTM. Just some minor nits;

@javiercn
Copy link
Member Author

🆙 📅

@javiercn javiercn merged commit a4a3668 into main Apr 16, 2021
@javiercn javiercn deleted the javiercn/generic-type-constraints branch April 16, 2021 17:03
@ghost ghost added this to the 6.0-preview4 milestone Apr 16, 2021
@SteveSandersonMS
Copy link
Member

@javiercn This looks awesome!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-blazor Includes: Blazor, Razor Components
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Allow generic type constraints
6 participants