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 0Real-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 typing2. @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 creditCombining 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 completedAdvanced 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' propertyMultiple 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
| Practice | Description | Example |
|---|---|---|
| Use Generics for reusability | Avoid code duplication | function get<T>(id: string): T |
| Add constraints when needed | Ensure type has required properties | <T extends IEntity> |
| Decorators for cross-cutting | Logging, caching, validation | @Cache @Log |
| Keep decorators simple | One responsibility per decorator | Separate @Validate and @Cache |
| Type inference over explicit | Let TS infer when possible | getFirst([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.
