angulartypescriptcompiler-errorsangular-componentsangular-template

Angular 19: TypeScript Error with Conditional Type Checking in Dynamic Table Component


I have a dynamic table component in Angular 19, which is built using a TableConfig object. This configuration creates the table's structure, including headers, columns, and rows. The rows property of TableConfig can contain an array of SupportTheme or Role objects.

My issue is that even if I check in my code that .roles exists, an error occurs when I attempt to access the roles property on a SupportTheme object. Although I have an if condition that ensures this code block is only executed when tconfig.type is 'SUPPORT_THEMES', the Angular compiler still throws the following error:

NG9: Property 'roles' does not exist on type 'SupportTheme | Role'.
  Property 'roles' does not exist on type 'Role'. [plugin angular-compiler]

This error doesn't make sense to me because the if condition should prevent any Role object from being processed in this block of code. (Also tried obj.hasOwnProperty('roles') = same result)

  <!-- Row Content -->
  @for (obj of tconfig.rows; track obj.id) {
    <tr class="border-b dark:border-gray-700 transition-colors duration-300">
      @if (tconfig.type === 'SUPPORT_THEMES') {
        <td class="px-3 py-3 text-sm text-gray-500">
          {{ obj.icon }}
        </td>
      }

      <th scope="row" class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white
                                             transition-colors duration-300 flex items-center">
        <div class="w-3 h-3 rounded-full inline-block me-1.5"
             [style.background-color]="obj.type === 'TEAMLIST' ? '#' + obj.color.toString(16).padStart(6, '0') : ''"></div>
        {{ obj.name }}
      </th>

      @if (tconfig.type === 'TEAMLIST') {
        @if (obj.support_level) {
          <td class="px-4 py-3">{{ getSupportLevel(obj.support_level) }}</td>
        }
      } @else if (tconfig.type === 'SUPPORT_THEMES') { // I also tried 'obj.hasOwnProperty('roles')'
        @for (role of obj.roles; track role.id) {
          <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-opacity-10
                      bg-blue-500 text-blue-700 dark:bg-opacity-20 dark:bg-blue-400 dark:text-blue-200
                      border border-blue-200 dark:!border-blue-600 shadow-sm hover:bg-opacity-20 hover:shadow
                      transition-all duration-200">
            {{ role.name }}
          </span>
        }
      }
    </tr>
  }

Relevant data types:

export interface TableConfig {
  type: 'SUPPORT_THEMES' | 'TEAMLIST';
  list_empty: string;
  dataLoading: boolean;

  columns: ColumnConfig[];
  rows: SupportTheme[] | Role[];
  action_btn: ButtonConfig[];
}

export interface SupportTheme {
  id: string;
  name: string;
  icon: string;
  roles: Role[];
}

export interface Role {
  id: string;
  name: string;
  // .. other attributes .. no "roles" here ..

  // properties added by us
  support_level?: number;
}

Solution

  • You can leverage User Defined Type Guards - Type Predicates to ensure that the proper type is applied.

    Below we use the properties that differ between the two types to identify the typing to apply.

    isSupportType(obj: SupportTheme | Role): obj is SupportTheme {
      return (obj as SupportTheme).roles !== undefined;
    }
    
    isRoleType(obj: SupportTheme | Role): obj is Role {
      return (obj as Role).support_level !== undefined;
    }
    

    When we use this function inside an @if condition, the type which you set after the is (obj is SupportTheme) is taken.

      } @else if (tconfig.type === 'SUPPORT_THEMES' && isSupportType(obj)) { 
        @for (role of obj.roles; track role.id) {
          <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-opacity-10
                      bg-blue-500 text-blue-700 dark:bg-opacity-20 dark:bg-blue-400 dark:text-blue-200
                      border border-blue-200 dark:!border-blue-600 shadow-sm hover:bg-opacity-20 hover:shadow
                      transition-all duration-200">
            {{ role.name }}
          </span>
        }
    

    Full Code:

    import { Component } from '@angular/core';
    import { bootstrapApplication } from '@angular/platform-browser';
    export interface ColumnConfig {}
    export interface ButtonConfig {}
    export interface TableConfig {
      type: 'SUPPORT_THEMES' | 'TEAMLIST';
      list_empty: string;
      dataLoading: boolean;
    
      columns: ColumnConfig[];
      rows: SupportTheme[] | Role[];
      action_btn: ButtonConfig[];
    }
    
    export interface SupportTheme {
      id: string;
      name: string;
      icon: string;
      roles: Role[];
      color: string;
      type: string;
    }
    
    export interface Role {
      id: string;
      name: string;
      support_level?: number;
    }
    
    @Component({
      selector: 'app-root',
      template: `
      <table>
        <!-- Row Content -->
      @for (obj of tconfig.rows; track obj.id) {
        <tr class="border-b dark:border-gray-700 transition-colors duration-300">
        @if(isSupportType(obj)) {
          @if (tconfig.type === 'SUPPORT_THEMES') {
            <td class="px-3 py-3 text-sm text-gray-500">
              {{ obj.icon }}
            </td>
          }
    
          <th scope="row" class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white
                                                transition-colors duration-300 flex items-center">
            <div class="w-3 h-3 rounded-full inline-block me-1.5"
                [style.background-color]="obj.type === 'TEAMLIST' ? '#' + obj.color.toString().padStart(6, '0') : ''"></div>
            {{ obj.name }}
          </th>
        }
          @if (tconfig.type === 'TEAMLIST' && isRoleType(obj)) {
            @if (obj.support_level) {
              <td class="px-4 py-3">{{ getSupportLevel(obj.support_level) }}</td>
            }
          } @else if (tconfig.type === 'SUPPORT_THEMES' && isSupportType(obj)) { 
            @for (role of obj.roles; track role.id) {
              <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-opacity-10
                          bg-blue-500 text-blue-700 dark:bg-opacity-20 dark:bg-blue-400 dark:text-blue-200
                          border border-blue-200 dark:!border-blue-600 shadow-sm hover:bg-opacity-20 hover:shadow
                          transition-all duration-200">
                {{ role.name }}
              </span>
            }
          }
        </tr>
      }
      </table>
      `,
    })
    export class App {
      name = 'Angular';
      tconfig: TableConfig = {
        type: 'SUPPORT_THEMES',
        list_empty: 'test',
        dataLoading: false,
        columns: [],
        action_btn: [],
        rows: [
          {
            id: '1',
            icon: 'test',
            name: 'qwer',
            roles: [],
            color: '#000000',
            type: 'asdf',
          },
        ],
      };
    
        isSupportType(obj: SupportTheme | Role): obj is SupportTheme {
          return (obj as SupportTheme).roles !== undefined;
        }
    
        isRoleType(obj: SupportTheme | Role): obj is Role {
          return (obj as Role).support_level !== undefined;
        }
    
      getSupportLevel(test: any) {
        return '';
      }
    }
    
    bootstrapApplication(App);
    

    Stackblitz Demo