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

Define generic of ng-template #28731

Open
waterplea opened this issue Feb 14, 2019 · 42 comments
Open

Define generic of ng-template #28731

waterplea opened this issue Feb 14, 2019 · 42 comments
Assignees
Labels
area: compiler Issues related to `ngc`, Angular's template compiler area: core Issues related to the framework runtime compiler: template type-checking core: ng-template and *microsyntax feature: under consideration Feature request for which voting has completed and the request is now under consideration feature Issue that requests a new feature
Milestone

Comments

@waterplea
Copy link
Contributor

🚀 feature request

Relevant Package

This feature request is for @angular/language-service

Description

I quite often use ng-templates in my application to later include them in the *ngTemplateOutlet and pass them the context. However when I define the template itself, it doesn't know the interface the context will have, so say this:

<ng-template #willUseLater
             let-item
             let-index="index">
    {{index}} — {{item}}
</ng-template>

Gives me an error ng: The template context does not defined a member called 'index'. (note also a typo here)

Under ng-template there's TemplateRef<T>, but there is no way to define that T.

Describe the solution you'd like

Provide a way to define the generic in place without extra overhead

Describe alternatives you've considered

At least disable this error as it has no purpose if you have any complex template usage

@pkozlowski-opensource pkozlowski-opensource added the area: language-service Issues related to Angular's VS Code language service label Feb 14, 2019
@ngbot ngbot bot added this to the needsTriage milestone Feb 14, 2019
@pkozlowski-opensource pkozlowski-opensource added the area: core Issues related to the framework runtime label Feb 14, 2019
@pkozlowski-opensource
Copy link
Member

Oh, this is an interesting one ! @waterplea did you consider any options for "Provide a way to define the generic in place without extra overhead"?

Somehow we would have to define context type in a template but my first knee-jerk reaction is that it would be... I don't know, like writing TS code in a template. Anyway, interested in other people thoughts...

@waterplea
Copy link
Contributor Author

Well, since it digs in the source code files statically anyway it could try to find the uses of this template. If you have it with context, you most certainly have a template variable for it. So there's a chance to find it in components' ViewChild/ren or if it is passed as an input to some other component inside template directly — you could try to find a corresponding input's type. However that is probably not that easy and there's also a case (how I actually use it) of additional directive:

<ng-template #ref="myDirective" myDirective let-item let-index="index">
  ...
</ng-template>

and

class MyDirective<T> {
  constructor(
    @Inject(TemplateRef) templateRef: TemplateRef<{$implicit: T, index: number}>
  )
}

Which makes it even more difficult.

@alxhub alxhub added the feature Issue that requests a new feature label Apr 18, 2019
@ngbot ngbot bot modified the milestones: needsTriage, Backlog Apr 18, 2019
@Airblader
Copy link
Contributor

I think having some [ngTemplateContextType] would be useful here, and in line with ngTemplateOutlet* on ng-container.

Yes it would be some level of typing in the template, but usually you'd probably just bind a specific type rather than define full interfaces there. Also @waterplea outlined issues with inferring it automatically, and at the end of the day having type information is better than not having type information.

Trying to infer types when not defined explicitly is a separate effort that could be done on top of providing the expected type information. The language service can also analyze that ngTemplateOutletContext is set according to this type in the templates.

@Airblader
Copy link
Contributor

Airblader commented Nov 18, 2019

With the ngTemplateContextGuard and ngAcceptInputType_* functions as recent additions, Angular has moved towards pragmatic decisions to improve type safety support in Angular. In the spirit of that I would like to bring attention to this issue again and propose a similarly pragmatic approach.

Lack of type safety for ng-template is one of the big places in Angular at the moment where type safety really suffers.

Contrary to my half-baked proposal above I would instead suggest something like this, which closely follows the implementation of ngTemplateContextGuard which can be done from directive side:

interface Article {
    price: number;
}

interface Context<T> {
    $implicit: T;
}

@Component({
    template: `
        <ng-template [ngTemplateContextGuard]="guardFn" let-article>
            {{ article.price }} <!-- OK -->
            {{ article.prize }} <!-- Error -->
        </ng-template>
    `,
})
export class FooComponent {

    public readonly guardFn = (ctx: any): ctx is Context<Article> => true;

}

This also avoids the issue of introducing types directly into the template and would still leave automatic discovery of the proper type to be an option in the future.

Ideally using *ngTemplateOutlet should then throw an error if the context type doesn't match (otherwise the chain of type safety is broken). I'm not sure if this is problematic from an implementation perspective, and if so then this would be a useful feature regardless.

CC @alxhub @IgorMinar

@pkozlowski-opensource pkozlowski-opensource added core: ng-template and *microsyntax area: compiler Issues related to `ngc`, Angular's template compiler and removed area: language-service Issues related to Angular's VS Code language service labels Mar 16, 2020
@Lonli-Lokli
Copy link

Is there update?

@marsc
Copy link

marsc commented Jul 9, 2020

Would be a great addition.

@Airblader
Copy link
Contributor

There's repeated calls for better documentation on templates, for example #39349, and promoting template usage is slightly dangerous with the lack of type safety we currently have in the framework.

So I'd like to shine light on this issue and my proposal above again as well as ask whether this issue qualifies for the cross-cutting types label.

@Lonli-Lokli
Copy link

Is there any workaround to add type context for let-variables? Even without language service, just for compiler?

@kentkwee
Copy link

kentkwee commented Feb 20, 2021

@Lonli-Lokli
Yes, there is a a workaround.
You can define the following directive.

import { Directive, Input } from '@angular/core';

@Directive({
  selector: '[appTemplateContext]',
})
export class TemplateContextDirective<T> {
  @Input()
  appTemplateContext?: T;

  static ngTemplateContextGuard<T>(
    dir: TemplateContextDirective<T>,
    ctx: unknown
  ): ctx is T {
    return true;
  }
}

Then you need a variable with your desired context type, and you can use it like follows

import { Component } from '@angular/core';

type MyTemplateContext = { $implicit: { prop: number }; test: string };
@Component({
  selector: 'app-template-sample',
  templateUrl: './template-sample.component.html',
})
export class TemplateSampleComponent {
  myTemplateContext?: MyTemplateContext;
}
<ng-template #myTemplate [appTemplateContext]="myTemplateContext" let-item let-test="test">
    <ul>
        <li>Prop: {{ item.prop + '' }}</li>
        <li>DoesNotExist: {{ item.doesNotExist + '' }}</li>
        <li>SampleProp: {{ test + '' }}</li>
    </ul>
</ng-template>

After that you get a type error with the new ivy language services:
image

@Lonli-Lokli
Copy link

@kentkwee thanks, looks promising!
myTemplateContext could be created in this directive instead of @inpputting it, but there is no possibility to fetch actual type from parameters right?

@kentkwee
Copy link

kentkwee commented Feb 20, 2021

@Lonli-Lokli
You can also use [appTemplateContext]="{ $implicit: {prop: 42}, test: 'abc'}" and the type will be inferred as { $implicit: {prop: number; test: string} if it is that what you mean.

@Lonli-Lokli
Copy link

@kentkwee my goal is [appTemplateContext]="item" but it's seems like not allowed now, unfortunately.

@kentkwee
Copy link

kentkwee commented Feb 21, 2021

Okay. I understand.
If you only want to specify the implicit context you could use a directive like

import { Directive, Input } from '@angular/core';

type ImplicitContext<T> = { $implicit: T; [index: string]: any };

@Directive({
  selector: '[appTemplateContextImplicit]',
})
export class TemplateContextImplicitDirective<T> {
  @Input()
  appTemplateContextImplicit?: T;

  constructor() {}

  static ngTemplateContextGuard<T>(
    dir: TemplateContextImplicitDirective<T>,
    ctx: unknown
  ): ctx is ImplicitContext<T> {
    return true;
  }
}

@Lonli-Lokli
Copy link

@kentkwee it does not work, you can check it there https://github.com/Lonli-Lokli/templateContextGuard
error TS2339: Property 'abra' does not exist on type 'string'. - it should use type Person

@Airblader
Copy link
Contributor

@Lonli-Lokli You still need to actually pass the appTemplateContextImplicit input, otherwise the type cannot be inferred for this to work.

@nigrosimone
Copy link
Contributor

nigrosimone commented Aug 23, 2022

Disclaimer! I'm the author of ng-as Angular library with pipe and directive for type casting template variables.

Casting with directirve eg.:

import { Component } from '@angular/core';

// your interface, but also work with any typescript type (class, type, etc.)
interface Person {
  name: string;
}

@Component({
  selector: 'app-root',
  template: `
  <ng-container *ngTemplateOutlet="personTemplate; context: {$implicit: person}"></ng-container>
  <ng-template #personTemplate [ngAs]="Person" let-person>
    <span>Hello {{ person.name }}!</span>
  </ng-template>
  `,
})
export class AppComponent {
  // NOTE: If you have "strictPropertyInitialization" enabled, 
  // you will need to add a non-null assertion (!)
  public Person!: Person; // publish your interface into html template
  person: Person = { name: 'Simone' }; // the data
}

Casting with pipe eg.:

import { Component } from '@angular/core';

// your interface, but also work with any typescript type (class, type, etc.)
interface Person {
  name: string;
}

@Component({
  selector: 'app-root',
  template: `
  <ng-container *ngTemplateOutlet="personTemplate; context: {$implicit: person}"></ng-container>
  <ng-template #personTemplate let-person>
    <span>Hello {{ (person | as: Person).name }}!</span>
  </ng-template>
  `,
})
export class AppComponent {
  // NOTE: If you have "strictPropertyInitialization" enabled, 
  // you will need to add a non-null assertion (!)
  public Person!: Person; // publish your interface into html template
  person: Person = { name: 'Simone' }; // the data
}

source of pipe:

import { Pipe, PipeTransform } from "@angular/core";

@Pipe({ name: 'as', pure: true })
export class NgAsPipe implements PipeTransform {
  // eslint-disable-next-line no-unused-vars
  transform<T>(input: unknown, baseItem: T | undefined): T {
    return input as unknown as T;
  }
}

source of directive:

import { Directive, Input } from "@angular/core";

interface NgAsContext<T> {
  ngLet: T;
  $implicit: T;
}

@Directive({ selector: '[ngAs]' })
export class NgAsDirective<T> {
  @Input() ngAs!: T;

  static ngTemplateGuard_ngLet: 'binding';

  static ngTemplateContextGuard<T>(dir: NgAsDirective<T>, ctx: any): ctx is NgAsContext<Exclude<T, false | 0 | '' | null | undefined>> {
      return true;
  }
}

That's all!

alt text

More info on: https://www.npmjs.com/package/ng-as

@pburgmer
Copy link

Would be really nice to have a build in solution without workarounds and duplication. We use ng-template and ngTemplateOutlet a lot.

@hakimio
Copy link

hakimio commented Sep 29, 2022

@pburgmer since it doesn't seem to be likely that they will implement this any time soon, I would recommend to try out ng-polymorpheus which not only allows you to type template context but also has some other cool features.

@pburgmer
Copy link

pburgmer commented Oct 2, 2022

@hakimio Thanks for the hint. Looks very promising. Will try it.

@ZenSide
Copy link

ZenSide commented Dec 16, 2022

as parrallel info, here is an interesting solution to strong type variable in template, here for mat-table of angular material : https://nartc.me/blog/typed-mat-cell-def/

@scurk1415
Copy link

So is there any progress on this?

@simeyla
Copy link

simeyla commented Jul 9, 2023

Simplest Workaround:

Every time I try to research this to see if something new has come along I invariably revert back to the simplicity of just creating a new typed scoped using *ngIf.

<ng-template #productTemplate let-product="product">
    <ng-container *ngIf="asProductType(product); let product">
        {{ product.name }}
    </ng-container>
</ng-template>

Where asProductType is just a function that returns a typed version of whatever is passed in.

Notice this is NOT a typeguard and there's no new directive or pipe involved.

asProductType(product: any) 
{
    return product as ProductListProduct;
}

Keeping consistent naming means that if generic templates are ever introduced I can search for *ngIf="as in order to find places I've done this.

@wwarby
Copy link

wwarby commented Jul 10, 2023

Simplest Workaround:

Every time I try to research this to see if something new has come along I invariably revert back to the simplicity of just creating a new typed scoped using *ngIf.

<ng-template #productTemplate let-product="product">
    <ng-container *ngIf="asProductType(product); let product">
        {{ product.name }}
    </ng-container>
</ng-template>

Where asProductType is just a function that returns a typed version of whatever is passed in. Note this is NOT a typeguard and there's no new directive or pipe.

asProductType(product: any) 
{
    return product as ProductListProduct;
}

Keeping consistent naming means that if generic templates are ever introduced I can search for *ngIf="as in order to find places I've done this.

That's a great workaround I hadn't thought of, thanks for sharing :)

@lixaotec
Copy link

it would be nice if template accepts the typing like:

<ng-template #productTemplate let-product="product as Product">
{{ product.name }}

@hakimio
Copy link

hakimio commented Sep 21, 2023

If you use strict template checking, you can use the following simple trick to type your ng-template parameters.

  1. Create typed-template.directive.ts:
import {Directive, Input} from '@angular/core';

@Directive({
    // eslint-disable-next-line @angular-eslint/directive-selector
    selector: 'ng-template[typedTemplate]',
    standalone: true
})
export class TypedTemplateDirective<TypeToken> {

    @Input('typedTemplate')
    typeToken: TypeToken;

    static ngTemplateContextGuard<TypeToken>(
        _dir: TypedTemplateDirective<TypeToken>,
        _ctx: unknown
    ): _ctx is TypeToken {
        return true;
    }

}
  1. Import and use the new directive:

app.component.html

<ng-template let-myNumber="myNumber" let-myText="myText" [typedTemplate]="templateType">
    <h2>{{ myText.toUpperCase() }}</h2>
    <p>{{ myNumber.toFixed(2) }}</p>
</ng-template>

app.component.ts

templateType: {
    myText: string;
    myNumber: number;
};

Tested in "WebStorm", should also work in "VS Code".

@sbokhan
Copy link

sbokhan commented Oct 23, 2023

One possible solution can be to use a directive for the context consumer and a pipe for the context provider.
And now, you only need to have a declared context type in the component to link the context provider with the consumer.

  1. Directive with ngTemplateContextGuard:
import { Directive, Input } from '@angular/core';

@Directive({ selector: 'ng-template[templateContextType]' })
export class TemplateContextTypeDirective<T> {
  @Input() protected templateContextType!: T;

  public static ngTemplateContextGuard<T>(
    dir: TemplateContextTypeDirective<T>,
    ctx: unknown
  ): ctx is T {
    return true;
  }
}
  1. Pipe for context type verification:
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'typify' })
export class TypifyPipe implements PipeTransform {
  public transform<T>(data: T, type: T): T {
    return data;
  }
}
  1. Declare context type in the component
import { Component, Input } from '@angular/core';

@Component({
  selector: 'products-list',
  templateUrl: './products-list.component.html',
})
export class ProductsList {
  protected contextType!: {
    products: Array<string>;
    noDataMessage: string;
    loadingMessage: string;
  };

  @Input() public products!: Array<string> | null;
}
  1. Use in template
<h2>Products list</h2>
<ng-container
  [ngTemplateOutlet]="
    products ? (products.length ? productsListTmpl : noDataTmpl) : loadingTmpl
  "
  [ngTemplateOutletContext]="
    {
      products: products,
      noDataMessage: 'There are no products so far',
      loadingMessage: 'Loading products...'
    } | typify: contextType
  "
></ng-container>

<ng-template
  #productsListTmpl
  let-products="products"
  [templateContextType]="contextType"
>
  <p *ngFor="let product of products">{{ product }}</p>
</ng-template>

<ng-template
  #loadingTmpl
  let-loadingMessage="loadingMessage"
  [templateContextType]="contextType"
>
  <p>{{ loadingMessage }}</p>
</ng-template>

<ng-template
  #noDataTmpl
  let-noDataMessage="noDataMessage"
  [templateContextType]="contextType"
>
  <p>{{ noDataMessage }}</p>
</ng-template>

Here is a simple demo.

@yjaaidi
Copy link
Contributor

yjaaidi commented Oct 24, 2023

What about defining templates outside of the component's template in a type-safe way?
Of course, this requires compiler changes and should have the same restrictions as the component's template (i.e. the template string should not be dynamic)

@Component({
  ...
  template: `<mc-user-list [actions]="actionsTemplate"/>`
})
class MyCmp {
  // Type is inferred to `TemplateRef<{user: User}>`
  actionsTemplate = createTemplate<{user: User}>(
    `<mc-button (click)="removeUser(user)">REMOVE</mc-button>`, {
    context: this, // or `context: { removeUser(user: User) { this.removeUser(user); }}`
    imports: [ButtonCmp]
  })
  
  removeUser(user: User) {
    ...
  }
}

@simeyla
Copy link

simeyla commented Feb 10, 2024

A few examples above use @Input to help infer the type of the context.

Important note: Currently this same approach does not work with a signal input(), even if strongly typed:

// does not work as the input to infer a type for the context guard
columnType: InputSignal<T> = input.required<T>();

It's not immediately clear why, my best guess is it's something related to the generated code from a decorator that wouldn't exist for signal inputs.

@scurk1415
Copy link

Does the workaround with ngTemplateContextGuard work if you have a built and npm released library that uses input signals?

I have a library that uses input signals and the ngTemplateContextGuard workaround works if the library is not built. But once i build it, the type of the $implicit object is unknown. No idea if it worked before with @input or if it is another problem.

This issue really needs to be looked at, because the developer experience is quite bad. Would be nice if it became a priority.

@krix
Copy link

krix commented May 15, 2024

Does the workaround with ngTemplateContextGuard work if you have a built and npm released library that uses input signals?

I have a library that uses input signals and the ngTemplateContextGuard workaround works if the library is not built. But once i build it, the type of the $implicit object is unknown. No idea if it worked before with @input or if it is another problem.

This issue really needs to be looked at, because the developer experience is quite bad. Would be nice if it became a priority.

This is also exactly my problem. A generic component published in our custom library is not correctly type-inferred when used inside a template.
It works as soon as I change the signal input to @input.

@ptandler
Copy link

ptandler commented Jun 7, 2024

I think Svelte 5 has a very elegant solution with their "snippets" that could be the source for inspiration how to evolve Angular here: https://svelte.dev/docs/svelte/snippet

@mauriziocescon
Copy link

mauriziocescon commented Jun 14, 2024

Umh... made a proposal some time ago #56056 (mostly DX driven without tech details on how to implement it).

How about using arrow functions / inputs / template vars? Idea: in a regular parent-child components relationship, an ng-template with let- is just a parent object (arrow func) passed to a child that is gonna close over it.

It's a different perspective where a component can accept arbitrary inputs (primitives, objects / functions, templates) and those inputs can be defined in templates (same story as @let). Then everything follows (signal) input rules.

Here is a pseudo-example (needs beautification):

// ----------------------------------------
// consumer-of-my-tree.component.ts 
@Component({
  selector: 'consumer-component',
  // ...
  template: `
  
    @ref customNode = (node) => {
        <ng-template>
            @if (node.desc) {
                {{ node.desc }}
            }
            // manageNodeClick is defined in ConsumerComponent
            <span [class]="node.style" (click)="manageNodeClick(node)">{{ node.label }}</span>
        </ng-template>
    }

    <my-tree-component [data]="dataStructure" [customNode]="customNode"></my-tree-component>
  `,
})
export class ConsumerComponent {...}

// ----------------------------------------
// my-tree.component.ts
export interface Node {
    id: string;
    desc?: string;
    label: string;
    style: string;
}

@Component({
  selector: 'my-tree-component',
  // ...
  template: `
    <!-- Of course  the usage of ngTemplateOutlet below 
        is just to provide ideas: requires further investigation / adaptations 
    -->

    for (node of nodes(); track node.id) {
        @let temp = customNode();
        <ng-container [ngTemplateOutlet]="temp(node)"></ng-container>
    }
  `,
})
export class MyTreeComponent {
    data = input.required<...>();

    // This is the place where the type is defined (I hope something like this is enough)
    customNode = input<(node: Node) => TemplateRef<any>>();
    
    nodes = signal<Node[]>([]); // populated started from data
}

@MarekMlodzianowski
Copy link

MarekMlodzianowski commented Dec 30, 2024

My approach to this problem (early solution, I haven't tested any performance yet):

pipe:

export class CastPipe implements PipeTransform {
	transform<t = any>(value: any, callback: (...args: t[]) => t): t {
		if (typeof callback !== 'function') throw new Error('param not supported');
		return callback(value);
	}
}

component:

castType = (value: unknown): MyUserType=> value as MyUserType;

template:

<ng-template
	#latestTemplate
	let-tempUser>
	@let user= tempUser| cast: castType;
	{{user.some.property}} {{user.name}} 
...
</ng-template>

@hakimio
Copy link

hakimio commented Dec 31, 2024

@MarekMlodzianowski the directive workaround is just as simple and requires much less code to type the ng-template. If you don't want to add the directive to your own project, you can also just use ng-polymorpheus library.
Anyway, all the possible workarounds have been mentioned in the thread already - no need to keep reinventing the wheel anymore.

@MarekMlodzianowski
Copy link

MarekMlodzianowski commented Jan 2, 2025

@hakimio I tested the directive workaround with Angular v19 / TypeScript 5.7.2 / VS Code 1.96.2, and it doesn't work for me. The let-xxx values are of type any—that's why I tried a different solution for folks like me.

@hakimio
Copy link

hakimio commented Jan 2, 2025

@MarekMlodzianowski I am using it with Angular 19 + TypeScript 5.6.3 + WebStorm 2024.3.1 & VS Code 1.96.2.

Here it's working with VS Code:

Image

Image

Image

Have you enabled strictTemplates in your tsconfig.json and have you updated your Angular Language Service extension?

"angularCompilerOptions": {
    "enableI18nLegacyMessageIdFormat": false,
    "strictInjectionParameters": true,
    "strictInputAccessModifiers": true,
    "strictTemplates": true
}

@MarekMlodzianowski
Copy link

MarekMlodzianowski commented Jan 2, 2025

I had the same config as you. After restarting the TypeScript service and VSCode a few times, the directive started working. Since my Angular app is wrapped in an NX monorepo, I suspect some aggressive caching might have been causing the issue. 🎈

@hakimio Thank you so much for your help! 😊

Image

Image

Edit: After refactoring the view (previously, I focused only on static types as shown in the screenshots), the directive is still not working (binding prints empty / undefined value). It seems the IDE correctly recognizes the type, but when the app is running, the let-value is missing. That’s strange, and I’ll investigate further to figure out what’s going on.

ng-template without it works fine... 🎢

Edit-2: Now it works :) The problem was $implicit used in my ng-components. 🎉

app.component.ts:
usersTemplateType!: { $implicit: Users };`
children.component.ts
<ng-container
    [ngTemplateOutlet]="rowItemTemplate()" //signal input
    [ngTemplateOutletContext]="{ $implicit: rowItem }" />

https://angular.dev/api/common/NgTemplateOutlet

@Harricpp
Copy link

Harricpp commented Jan 3, 2025

Thank you so much guys

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area: compiler Issues related to `ngc`, Angular's template compiler area: core Issues related to the framework runtime compiler: template type-checking core: ng-template and *microsyntax feature: under consideration Feature request for which voting has completed and the request is now under consideration feature Issue that requests a new feature
Projects
None yet
Development

No branches or pull requests