Skip to main content

TypeScript Advanced Features: Decorators and Generics for UI5 Developers

As TypeScript adoption grows in the SAP UI5 ecosystem, mastering advanced features like Decorators and Generics becomes essential for building maintainable, type-safe applications.

This guide explores these powerful TypeScript features through the lens of UI5 development, with practical examples that you can apply immediately to your projects.

Understanding Generics

What are Generics?

Generics allow you to write reusable code that works with multiple types while maintaining type safety. Think of them as "type parameters" — placeholders for actual types determined at usage time.

Basic Generic Function

// WITHOUT Generics - Specific to strings
function getFirstString(arr: string[]): string {
  return arr[0];
}

// Problem: Need separate function for numbers
function getFirstNumber(arr: number[]): number {
  return arr[0];
}

// WITH Generics - Works with any type
function getFirst<T>(arr: T[]): T {
  return arr[0];
}

// Usage - TypeScript infers the type
const firstString = getFirst(['a', 'b', 'c']);  // Type: string
const firstNumber = getFirst([1, 2, 3]);        // Type: number
const firstCustomer = getFirst([
  { id: '001', name: 'ACME' }
]);  // Type: { id: string; name: string; }

Generics in UI5 Context

Type-Safe Model Binding

import JSONModel from "sap/ui/model/json/JSONModel";
import View from "sap/ui/core/mvc/View";

// Generic type for model data
interface ICustomer {
  customerId: string;
  name: string;
  revenue: number;
}

interface IProduct {
  productId: string;
  description: string;
  price: number;
}

// Generic function to get typed model data
function getModelData<T>(view: View, modelName?: string): T {
  const model = view.getModel(modelName) as JSONModel;
  return model.getData() as T;
}

// Generic function to set typed model data
function setModelData<T>(view: View, data: T, modelName?: string): void {
  const model = view.getModel(modelName) as JSONModel;
  model.setData(data);
}

// Usage in controller
class CustomerController {
  onInit() {
    // Type-safe access to customer data
    const customerData = getModelData<ICustomer>(this.getView());
    console.log(customerData.name);  // TypeScript knows this property exists!
    
    // Type-safe update
    setModelData<ICustomer>(this.getView(), {
      customerId: "C001",
      name: "ACME Corp",
      revenue: 150000
    });
    
    // TypeScript error if wrong type:
    // setModelData<ICustomer>(this.getView(), {
    //   productId: "P001"  // ERROR: Type mismatch!
    // });
  }
}

Generic Service Class

import BaseObject from "sap/ui/base/Object";

// Generic CRUD service for any entity type
class EntityService<T extends { id: string }> extends BaseObject {
  private entities: Map<string, T> = new Map();

  // Create
  create(entity: T): void {
    if (this.entities.has(entity.id)) {
      throw new Error(`Entity with ID ${entity.id} already exists`);
    }
    this.entities.set(entity.id, entity);
  }

  // Read
  getById(id: string): T | undefined {
    return this.entities.get(id);
  }

  // Update
  update(entity: T): void {
    if (!this.entities.has(entity.id)) {
      throw new Error(`Entity with ID ${entity.id} not found`);
    }
    this.entities.set(entity.id, entity);
  }

  // Delete
  delete(id: string): boolean {
    return this.entities.delete(id);
  }

  // List all
  getAll(): T[] {
    return Array.from(this.entities.values());
  }

  // Filter with generic predicate
  filter(predicate: (entity: T) => boolean): T[] {
    return this.getAll().filter(predicate);
  }
}

// Usage with specific types
interface ICustomer {
  id: string;
  name: string;
  revenue: number;
}

interface IOrder {
  id: string;
  customerId: string;
  amount: number;
  status: 'OPEN' | 'CLOSED';
}

// Create typed services
const customerService = new EntityService<ICustomer>();
const orderService = new EntityService<IOrder>();

// Type-safe operations
customerService.create({
  id: 'C001',
  name: 'ACME Corp',
  revenue: 150000
});

orderService.create({
  id: 'O001',
  customerId: 'C001',
  amount: 5000,
  status: 'OPEN'
});

// Filter with type safety
const highValueCustomers = customerService.filter(
  (customer) => customer.revenue > 100000  // TypeScript knows customer has 'revenue'
);

const openOrders = orderService.filter(
  (order) => order.status === 'OPEN'  // TypeScript knows order has 'status'
);

Generic Promise Wrapper for OData

import ODataModel from "sap/ui/model/odata/v2/ODataModel";

// Generic OData read function
function odataRead<T>(
  model: ODataModel,
  path: string,
  filters?: any[]
): Promise<T> {
  return new Promise((resolve, reject) => {
    model.read(path, {
      filters: filters,
      success: (data: any) => {
        resolve(data.results || data as T);
      },
      error: (error: any) => {
        reject(error);
      }
    });
  });
}

// Generic OData create function
function odataCreate<T>(
  model: ODataModel,
  path: string,
  data: T
): Promise<T> {
  return new Promise((resolve, reject) => {
    model.create(path, data, {
      success: (createdData: any) => {
        resolve(createdData as T);
      },
      error: (error: any) => {
        reject(error);
      }
    });
  });
}

// Usage in controller
interface ICustomerOData {
  CustomerId: string;
  Name: string;
  Revenue: string;  // OData often returns numbers as strings
}

class Controller {
  async loadCustomers() {
    const model = this.getView().getModel() as ODataModel;
    
    try {
      // Type-safe OData read
      const customers = await odataRead<ICustomerOData[]>(
        model,
        "/CustomerSet"
      );
      
      // TypeScript knows the structure
      customers.forEach(customer => {
        console.log(`${customer.Name}: $${customer.Revenue}`);
      });
      
    } catch (error) {
      console.error("Failed to load customers:", error);
    }
  }

  async createCustomer() {
    const model = this.getView().getModel() as ODataModel;
    
    try {
      const newCustomer = await odataCreate<ICustomerOData>(
        model,
        "/CustomerSet",
        {
          CustomerId: "C999",
          Name: "New Corp",
          Revenue: "50000"
        }
      );
      
      console.log("Created:", newCustomer.CustomerId);
      
    } catch (error) {
      console.error("Failed to create customer:", error);
    }
  }
}

Understanding Decorators

What are Decorators?

Decorators are a TypeScript feature for adding metadata and behavior to classes, methods, properties, or parameters. They're heavily used in modern frameworks like Angular and NestJS, and can enhance UI5 development too.

Enabling Decorators

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES6",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Decorator Types

1. Class Decorators

// Class decorator for logging instantiation
function Loggable(constructor: Function) {
  console.log(`Class ${constructor.name} instantiated`);
}

@Loggable
class CustomerController extends BaseController {
  onInit() {
    // Controller initialization
  }
}
// Output when controller is created: "Class CustomerController instantiated"

2. Method Decorators

// Method decorator for performance measurement
function Measure(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const originalMethod = descriptor.value;

  descriptor.value = function (...args: any[]) {
    const start = performance.now();
    const result = originalMethod.apply(this, args);
    const end = performance.now();
    console.log(`${propertyKey} took ${end - start}ms`);
    return result;
  };

  return descriptor;
}

class DataService {
  @Measure
  loadCustomers() {
    // Simulate data loading
    const customers = [];
    for (let i = 0; i < 10000; i++) {
      customers.push({ id: i, name: `Customer ${i}` });
    }
    return customers;
  }
}

const service = new DataService();
service.loadCustomers();
// Output: "loadCustomers took 12.5ms"

3. Property Decorators

// Property decorator for validation
function MinValue(min: number) {
  return function (target: any, propertyKey: string) {
    let value: number;

    const getter = function () {
      return value;
    };

    const setter = function (newValue: number) {
      if (newValue < min) {
        throw new Error(`${propertyKey} must be at least ${min}`);
      }
      value = newValue;
    };

    Object.defineProperty(target, propertyKey, {
      get: getter,
      set: setter,
      enumerable: true,
      configurable: true
    });
  };
}

class Product {
  @MinValue(0)
  price: number;

  @MinValue(1)
  quantity: number;

  constructor(price: number, quantity: number) {
    this.price = price;
    this.quantity = quantity;
  }
}

const product = new Product(100, 5);  // OK
// const invalidProduct = new Product(-50, 5);  // ERROR: price must be at least 0

Real-World UI5 Decorator Examples

1. @Debounce Decorator for Search

// Debounce decorator to prevent excessive API calls
function Debounce(delay: number) {
  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    let timeout: number | null = null;
    const originalMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
      if (timeout) {
        clearTimeout(timeout);
      }
      
      timeout = setTimeout(() => {
        originalMethod.apply(this, args);
      }, delay);
    };

    return descriptor;
  };
}

// Usage in UI5 controller
import Controller from "sap/ui/core/mvc/Controller";

class SearchController extends Controller {
  
  @Debounce(300)  // 300ms debounce
  onSearch(event: any) {
    const query = event.getParameter("query");
    console.log("Searching for:", query);
    
    // Call backend API
    this.loadSearchResults(query);
  }

  private loadSearchResults(query: string) {
    // Actual API call here
  }
}

// Now user typing won't trigger search until 300ms after they stop typing

2. @Cache Decorator for Expensive Operations

// Cache decorator for memoization
function Cache(ttl: number = 60000) {  // Default 1 minute TTL
  const cache = new Map<string, { value: any; timestamp: number }>();

  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    const originalMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
      const key = `${propertyKey}_${JSON.stringify(args)}`;
      const cached = cache.get(key);

      // Check if cached and not expired
      if (cached && Date.now() - cached.timestamp < ttl) {
        console.log(`Returning cached result for ${propertyKey}`);
        return cached.value;
      }

      // Call original method
      const result = originalMethod.apply(this, args);
      
      // Cache result
      cache.set(key, { value: result, timestamp: Date.now() });
      
      return result;
    };

    return descriptor;
  };
}

// Usage
class CustomerService {
  @Cache(300000)  // Cache for 5 minutes
  async getCustomersByRegion(region: string) {
    console.log("Fetching from backend...");
    const response = await fetch(`/api/customers?region=${region}`);
    return await response.json();
  }
}

const service = new CustomerService();

// First call - hits backend
await service.getCustomersByRegion("EMEA");  // "Fetching from backend..."

// Second call within 5 minutes - uses cache
await service.getCustomersByRegion("EMEA");  // "Returning cached result for getCustomersByRegion"

3. @Authorize Decorator for Access Control

// Authorization decorator
function Authorize(requiredRole: string) {
  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    const originalMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
      // Get current user role (from session, model, etc.)
      const userRole = this.getUserRole();

      if (userRole !== requiredRole && userRole !== 'ADMIN') {
        throw new Error(`Unauthorized: ${requiredRole} role required`);
      }

      return originalMethod.apply(this, args);
    };

    return descriptor;
  };
}

// Usage in UI5 controller
class OrderController extends Controller {
  
  @Authorize("SALES_MANAGER")
  onApproveOrder(event: any) {
    const orderId = event.getSource().getBindingContext().getProperty("OrderId");
    this.approveOrder(orderId);
  }

  @Authorize("ADMIN")
  onDeleteOrder(event: any) {
    const orderId = event.getSource().getBindingContext().getProperty("OrderId");
    this.deleteOrder(orderId);
  }

  private getUserRole(): string {
    // Get from user model or session
    return this.getOwnerComponent().getModel("user").getProperty("/role");
  }

  private approveOrder(orderId: string) {
    // Approval logic
  }

  private deleteOrder(orderId: string) {
    // Deletion logic
  }
}

4. @Validate Decorator for Input Validation

// Validation decorator
function ValidateArgs(...validators: Array<(arg: any) => boolean>) {
  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    const originalMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
      // Validate each argument
      for (let i = 0; i < validators.length; i++) {
        if (!validators[i](args[i])) {
          throw new Error(
            `Validation failed for argument ${i + 1} in ${propertyKey}`
          );
        }
      }

      return originalMethod.apply(this, args);
    };

    return descriptor;
  };
}

// Validator functions
const isNotEmpty = (value: string) => value && value.trim().length > 0;
const isPositive = (value: number) => value > 0;
const isEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);

// Usage
class CustomerService {
  @ValidateArgs(isNotEmpty, isEmail, isPositive)
  createCustomer(name: string, email: string, creditLimit: number) {
    console.log(`Creating customer: ${name}, ${email}, ${creditLimit}`);
    // Create customer logic
  }
}

const service = new CustomerService();

// Valid call
service.createCustomer("ACME Corp", "info@acme.com", 50000);  // OK

// Invalid calls
// service.createCustomer("", "info@acme.com", 50000);  // ERROR: name empty
// service.createCustomer("ACME", "invalid-email", 50000);  // ERROR: invalid email
// service.createCustomer("ACME", "info@acme.com", -100);  // ERROR: negative credit

Combining Generics and Decorators

Generic Repository with Decorators

// Generic repository with caching and logging
interface IEntity {
  id: string;
}

// Decorator factory
function LogOperation() {
  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    const originalMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
      console.log(`[${new Date().toISOString()}] ${propertyKey}(${JSON.stringify(args)})`);
      const result = originalMethod.apply(this, args);
      console.log(`[${new Date().toISOString()}] ${propertyKey} completed`);
      return result;
    };

    return descriptor;
  };
}

// Generic repository class
class Repository<T extends IEntity> {
  private items: Map<string, T> = new Map();

  @LogOperation()
  @Cache(60000)
  findById(id: string): T | undefined {
    return this.items.get(id);
  }

  @LogOperation()
  save(item: T): void {
    this.items.set(item.id, item);
  }

  @LogOperation()
  delete(id: string): boolean {
    return this.items.delete(id);
  }

  @LogOperation()
  findAll(): T[] {
    return Array.from(this.items.values());
  }

  @LogOperation()
  findWhere(predicate: (item: T) => boolean): T[] {
    return this.findAll().filter(predicate);
  }
}

// Usage with specific types
interface ICustomer extends IEntity {
  name: string;
  revenue: number;
}

const customerRepo = new Repository<ICustomer>();

customerRepo.save({ id: 'C001', name: 'ACME', revenue: 150000 });
// Log: [2026-01-19T10:30:00.000Z] save({"id":"C001","name":"ACME","revenue":150000})
// Log: [2026-01-19T10:30:00.001Z] save completed

const customer = customerRepo.findById('C001');
// Log: [2026-01-19T10:30:05.000Z] findById("C001")
// Log: [2026-01-19T10:30:05.001Z] findById completed

Advanced Generic Patterns

Generic Constraints

// Constraint: T must have 'id' and 'name' properties
interface IIdentifiable {
  id: string;
  name: string;
}

function displayEntity<T extends IIdentifiable>(entity: T): void {
  console.log(`[${entity.id}] ${entity.name}`);
}

// Valid
displayEntity({ id: 'C001', name: 'ACME', extra: 'data' });  // OK

// Invalid
// displayEntity({ id: 'C001' });  // ERROR: missing 'name' property

Multiple Type Parameters

// Generic function with two type parameters
function mapEntities<TSource, TTarget>(
  source: TSource[],
  mapper: (item: TSource) => TTarget
): TTarget[] {
  return source.map(mapper);
}

// Usage
interface IODataCustomer {
  CustomerId: string;
  Name: string;
  Revenue: string;
}

interface ICustomer {
  id: string;
  name: string;
  revenue: number;
}

const odataCustomers: IODataCustomer[] = [
  { CustomerId: 'C001', Name: 'ACME', Revenue: '150000' }
];

const customers = mapEntities<IODataCustomer, ICustomer>(
  odataCustomers,
  (odata) => ({
    id: odata.CustomerId,
    name: odata.Name,
    revenue: parseFloat(odata.Revenue)
  })
);

Best Practices

PracticeDescriptionExample
Use Generics for reusabilityAvoid code duplicationfunction get<T>(id: string): T
Add constraints when neededEnsure type has required properties<T extends IEntity>
Decorators for cross-cuttingLogging, caching, validation@Cache @Log
Keep decorators simpleOne responsibility per decoratorSeparate @Validate and @Cache
Type inference over explicitLet TS infer when possiblegetFirst([1, 2]) vs getFirst<number>([1, 2])

Common Pitfalls

❌ Pitfall 1: Over-using Generics

// BAD - Generic not needed
function addOne<T extends number>(num: T): T {
  return (num + 1) as T;
}

// GOOD - Simple type is enough
function addOne(num: number): number {
  return num + 1;
}

❌ Pitfall 2: Decorator Side Effects

// BAD - Mutating arguments
function BadDecorator(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  descriptor.value = function(...args: any[]) {
    args[0] = "MODIFIED";  // Unexpected side effect!
    return originalMethod.apply(this, args);
  };
}

// GOOD - Read-only operations
function GoodDecorator(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  descriptor.value = function(...args: any[]) {
    console.log("Args:", args);  // Just logging, no mutation
    return originalMethod.apply(this, args);
  };
}

Conclusion

Generics and Decorators are powerful TypeScript features that elevate UI5 development:

  • Generics → Type-safe reusable code
  • Decorators → Clean separation of concerns
  • ✅ Together → Production-grade TypeScript applications

Master these features, and your UI5 TypeScript code will be more maintainable, type-safe, and elegant.

About the Author: Yogesh Pandey is a passionate developer and consultant specializing in SAP technologies and full-stack development.