admin管理员组

文章数量:1356873

I have an Angular component that uses a few generic types. It will set one input based on the type input.

export interface IEmployee {
  description: string;
  employeeNum: string;
  costCenter: string;
}

export interface IEquipment {
  description: string;
  equipmentNum: string;
}

@Component({
  selector: 'app-employee-equipment-editor',
  template: `
  <h1>List of {{type}}</h1>
  <ul>
    @for (item of currentList; track $index) {
      <li>
        {{item.description}}
        <br />
        @if(type === 'employees'){
          <span>#{{item.employeeNum}}</span>
        } @else {
          <span>#{{item.equipmentNum}}</span>
        }
      </li>
    }
  </ul>
  `,
})
export class EmployeeEquipmentEditorComponent<
  TType = 'employees' | 'equipment',
  TEntity = TType extends 'employees' ? IEmployee : IEquipment
> {
  @Input({ required: true }) public type!: TType;
  @Input({ required: true }) public currentList!: Array<TEntity>;
}

StackBlitz

Even though both IEmployee and IEquipment have a description property, I get this error in the template:

Property 'description' does not exist on type 'TEntity'.

And if I were to get beyond that, how can I access different properties on the two different types? Like where I need to access employeeNum vs. equipmentNum? I get the same error that the property does not exist on TEntity.

This is a simplified demo. in my real code base I do not have the ability to change what an IEmployee or IEquipment object look like. I need this component to work with them as they are.

I have an Angular component that uses a few generic types. It will set one input based on the type input.

export interface IEmployee {
  description: string;
  employeeNum: string;
  costCenter: string;
}

export interface IEquipment {
  description: string;
  equipmentNum: string;
}

@Component({
  selector: 'app-employee-equipment-editor',
  template: `
  <h1>List of {{type}}</h1>
  <ul>
    @for (item of currentList; track $index) {
      <li>
        {{item.description}}
        <br />
        @if(type === 'employees'){
          <span>#{{item.employeeNum}}</span>
        } @else {
          <span>#{{item.equipmentNum}}</span>
        }
      </li>
    }
  </ul>
  `,
})
export class EmployeeEquipmentEditorComponent<
  TType = 'employees' | 'equipment',
  TEntity = TType extends 'employees' ? IEmployee : IEquipment
> {
  @Input({ required: true }) public type!: TType;
  @Input({ required: true }) public currentList!: Array<TEntity>;
}

StackBlitz

Even though both IEmployee and IEquipment have a description property, I get this error in the template:

Property 'description' does not exist on type 'TEntity'.

And if I were to get beyond that, how can I access different properties on the two different types? Like where I need to access employeeNum vs. equipmentNum? I get the same error that the property does not exist on TEntity.

This is a simplified demo. in my real code base I do not have the ability to change what an IEmployee or IEquipment object look like. I need this component to work with them as they are.

Share Improve this question edited Mar 31 at 22:40 Chris Barr asked Mar 31 at 12:35 Chris BarrChris Barr 34.3k28 gold badges103 silver badges153 bronze badges 3
  • Note angular is irrelevant: tsplay.dev/we38gm – jonrsharpe Commented Mar 31 at 12:43
  • @jonrsharpe ok, yes that makes sense. If this were pure TS for me I could probably do a check with instanceof, however I do not have access to stuff like that in an Angular HTML template – Chris Barr Commented Mar 31 at 12:52
  • The simpler thing to do is to have a component per type and decide at template time which component to use. If there's a lot of shared code, extract it into a service that gets injected into both, components that are composed into both, or functions that are called by both. – D M Commented Mar 31 at 14:41
Add a comment  | 

2 Answers 2

Reset to default 1

The issue turned out to be the = in the generics, which gave it a default alue, but made it optional to be changed to anything. I had to use extends instead, and then everything worked as I expected it to. I found a different way to remap the employeeNum and equipmentNum properties for my purposes

So, in general, here is the fix with the generics

export interface IEmployee {
  description: string;
  employeeNum: string;
  costCenter: string;
}

export interface IEquipment {
  description: string;
  equipmentNum: string;
}

@Component({
  selector: 'app-employee-equipment-editor',
  template: `
  <h1>List of {{type}}</h1>
  <ul>
    @for (item of currentList; track $index) {
      <li>
        {{item.description}}
        <br />
        @if(type === 'employees'){
          <span>#{{item.employeeNum}}</span>
        } @else {
          <span>#{{item.equipmentNum}}</span>
        }
      </li>
    }
  </ul>
  `,
})
export class EmployeeEquipmentEditorComponent<
  TType extends 'employees' | 'equipment',
  TEntity extends TType extends 'employees' ? IEmployee : IEquipment
> {
  @Input({ required: true }) public type!: TType;
  @Input({ required: true }) public currentList!: Array<TEntity>;
}

And here is the plain TS version on the TS Playground

It seems you overcomplicated it a bit with your generic type definition in the header. First up, if you do not define the types in the class generic arguments, but rather on the properties, it fixes your description problem, because now typescript can deduce that the key description is shared with the same property on both interfaces.

export class EmployeeEquipmentEditorComponent {
  @Input({ required: true }) public type!: 'employees' | 'equipment';
  @Input({ required: true }) public currentList!: Array<IEmployee | IEquipment>;

Next we can define type predicates for the IEmployee (or similar for IEquipment). this looks the following:

isEmployee(entry: IEmployee | IEquipment): entry is IEmployee {
  return this.type === 'employees';
}

Now we can use it in the template, the following way:

@if(isEmployee(item)){
   <span>#{{item.employeeNum}}</span>
} @else {
  <span>#{{item.equipmentNum}}</span>
}

Note how you have the correct type inside the if statement, because typescript knows, that after the if check, it must be of the type IEmployee (or IEquipment in the else part cause that are the only two options. You can even add more types if you want)

Full Code:

import { Component, Input } from '@angular/core';
import { IEmployee, IEquipment } from './app.model';

@Component({
  selector: 'app-employee-equipment-editor',
  template: `
  <h1>List of {{type}}</h1>
  <ul>
    @for (item of currentList; track $index) {
      <li>
        {{item.description}}
        <br />
        @if(isEmployee(item)){
          <span>#{{item.employeeNum}}</span>
        } @else {
          <span>#{{item.equipmentNum}}</span>
        }
      </li>
    }
  </ul>
  `,
})
export class EmployeeEquipmentEditorComponent {
  @Input({ required: true }) public type!: 'employees' | 'equipment';
  @Input({ required: true }) public currentList!: Array<IEmployee | IEquipment>;

  isEmployee(entry: IEmployee | IEquipment): entry is IEmployee {
    return this.type === 'employees';
  }
}

https://stackblitz/edit/stackblitz-starters-5u4asayr?file=src%2Femployee-equipment-editorponent.ts

If you want to optimize it a bit further, you don't have to pass the type, but deduce it based on the properties on the object. E.g Like this:

  isEmployee(entry: IEmployee | IEquipment): entry is IEmployee {
    return 'employeeNum' in entry && 'costCenter' in entry;
  }

This is helpfull, if you have a mixed list.

本文标签: How to use TypeScript generics in an Angular component templateStack Overflow