Typescript development

Metadata

Decorators

We use decorators to describe metadata.

List of all available decorators:

import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
import slot from "@ui5/webcomponents-base/dist/decorators/slot.js";
import event from "@ui5/webcomponents-base/dist/decorators/event.js";
import languageAware from "@ui5/webcomponents-base/dist/decorators/languageAware.js";
import themeAware from "@ui5/webcomponents-base/dist/decorators/themeAware.js";
import fastNavigation from "@ui5/webcomponents-base/dist/decorators/fastNavigation.js";

Types of decorators

Class decorators

These are used outside the class declaration and describe the class:

import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
import event from "@ui5/webcomponents-base/dist/decorators/event.js";
import languageAware from "@ui5/webcomponents-base/dist/decorators/languageAware.js";
import themeAware from "@ui5/webcomponents-base/dist/decorators/themeAware.js";
import fastNavigation from "@ui5/webcomponents-base/dist/decorators/fastNavigation.js";

Example:

@customElement("ui5-menu")
@event("item-click", {
	detail: {
		item: {
			type: Object,
		},
		text: {
			type: String,
		},
	},
})
class MyClass extends UI5Element {

}

Property decorators

These are used inside the class and are associated with accessors (class members). Such decorators are used for properties and slots:

import property from "@ui5/webcomponents-base/dist/decorators/property.js";
import slot from "@ui5/webcomponents-base/dist/decorators/slot.js";

Example:

class MyClass extends UI5Element {
	@property({ type: Boolean })
	open!:boolean;
}

Defining the tag and other settings

Use @customElement as a replacement for the metadata tag setting.

Use @themeAware, @languageAware and @fastNavigation where needed. Note that these do not accept parameters.

Example:

@customElement("my-element-name")
@languageAware
@themeAware
@fastNavigation

Defining properties (@property)

Pass an object with the settings (type, defaultValue, etc.), defining the property, as the only parameter to the @property decorator.

  • String is the default type, no need to explicitly write it
/**
 * Defines the header text of the menu (displayed on mobile).
 *
 * @name sap.ui.webc.main.Menu.prototype.headerText
 * @type {string}
 * @defaultvalue ""
 * @public
 */
@property()
headerText!: string;
  • use Typescript enum for enums (do not extend DataType)
/**
 * Defines the component design.
 *
 * <br/><br/>
 * <b>The available values are:</b>
 *
 * <ul>
 * <li><code>Default</code></li>
 * <li><code>Emphasized</code></li>
 * <li><code>Positive</code></li>
 * <li><code>Negative</code></li>
 * <li><code>Transparent</code></li>
 * <li><code>Attention</code></li>
 * </ul>
 *
 * @type {sap.ui.webc.main.types.ButtonDesign}
 * @name sap.ui.webc.main.Button.prototype.design
 * @defaultvalue "Default"
 * @public
 */
@property({ type: ButtonDesign, defaultValue: ButtonDesign.Default })
design!: ButtonDesign;
  • use validator instead of type for DataType descendants (although type still works for compatibility)
/**
 * Defines component's timestamp.
 * <b>Note:</b> set by the Calendar component
 * @type {sap.ui.webc.base.types.Integer}
 * @name sap.ui.webc.main.CalendarHeader.prototype.timestamp
 * @public
 */
@property({ validator: Integer })
timestamp?: number;

The validator setting is preferable to type as it avoids confusion with the actual Typescript type (i.e. number in this example)

  • Note the difference between Typescript types (string, boolean) and Javascript constructors (String, Boolean).

The former are used for Typescript class members, and the latter (as before) for the metadata settings.

@property({ type: Boolean })
hidden!: boolean;
  • Important: set the @name JSDoc annotation for all public properties as JSDoc cannot associate the JSDoc comment with the property in the code. This will not be necessary once we’ve switched to TypeDoc.

  • Use ? for all metadata properties that may be undefined, and ! for all other metadata properties.

As a rule of thumb, String properties are always defined with ! unless you specifically set defaultValue: undefined and validator properties are always defined with ? unless you specify a default value. Boolean properties are always defined with ! as they always have a default value of false.

  • Important: never initialize metadata properties. Use defaultValue instead.

Wrong:

class Button extends UI5Element {
	@property({ type: ButtonDesign })
	design: ButtonDesign = ButtonDesign.Default;
}

Also Wrong:

class Button extends UI5Element {
	@property({ type: ButtonDesign })
	design: ButtonDesign;

	constructor() {
		super();
		this.design = ButtonDesign.Default;
	}
}

Correct:

class Button extends UI5Element {
	@property({type: ButtonDesign, defaultValue: ButtonDesign.Default })
	design!: ButtonDesign;
}

Note the usage of ! to tell Typescript that this variable will always be defined, since Typescript does not know that the framework will create a special getter for it.

Defining slots (@slot)

There are 3 common patterns for defining slots:

Default slot with propertyName

Before:

/**
 * @type {HTMLElement[]}
 */
"default": {
	type: HTMLElement,
	propertyName: "items",
}

After:

/**
 * @name sap.ui.webc.main.SomeComponent.prototype.default
 * @type {HTMLElement[]}
 */
@slot({ "default": true, type: HTMLElement })
items!: Array<SomeItem>

Use the propertyName as the class member, set "default": true in the decorator definition, and use prototype.default as the JSDoc @name.

Named slot

Before:

/**
 * @type {HTMLElement[]}
 */
content: {
	type: HTMLElement,
	invalidateOnChildChange: true,
}

After:

/**
 * @name sap.ui.webc.main.SomeComponent.prototype.content
 * @type {HTMLElement[]}
 */
@slot({ type: HTMLElement, invalidateOnChildChange: true })
content!: Array<HTMLElement>

Use the slot name as the class member, and again in the JSDoc @name.

Default slot without propertyName

Before:

/**
 * @type {HTMLElement[]}
 */
"default": {
	type: HTMLElement,
}

After:

/**
 * @name sap.ui.webc.main.SomeComponent.prototype.default
 * @type {HTMLElement[]}
 */

Only provide a JSDoc comment and do not create a class member for that slot.

What about managedSlots?

There isn’t a decorator for managedSlots (unlike for all other metadata entities). It is set automatically when you use at least one @slot decorator.

In essence, this means that if you need to access the slot content in your component’s code, the slots automatically need to be managed. Therefore, whenever you use @slot, the managedSlots setting is automatically set.

Defining events

  • The @event decorator must be used outside the class (contrary to @property and @slot).
  • You must provide a JSDoc @name annotation with #

Example:

/**
 * Fired when an item is activated, unless the item's <code>type</code> property
 * is set to <code>Inactive</code>.
 *
 * @event sap.ui.webc.main.List#item-click
 * @allowPreventDefault
 * @param {HTMLElement} item The clicked item.
 * @public
 */
@event("item-click", {
	detail: {
		item: { type: HTMLElement },
	},
})

Events

There are a couple of rules to follow when creating and using events

  1. Use the @event decorator:
/**
 * Fired when an item is activated, unless the item's <code>type</code> property
 * is set to <code>Inactive</code>.
 *
 * @event sap.ui.webc.main.List#item-click
 * @allowPreventDefault
 * @param {HTMLElement} item The clicked item.
 * @public
 */
@event("item-click", {
	detail: {
		item: { type: HTMLElement },
	},
})
  1. Create a type for the event parameter
type ListItemClickEventDetail {
	item: ListItemBase,
}
  1. Use the type when firing events
this.fireEvent<ListItemClickEventDetail>("item-click", { item })
  1. Export the type for the event detail
export type { ListItemClickEventDetail };

Then, the users of your component can import the detail type and pass it to CustomEvent, for example:

onItemClick(e: CustomEvent<ListItemClickEventDetail>) {
	console.log(e.detail.item);
}

Conventions and guidelines

Conventions


1. Rename "event" to "e" in the .ts files as it collides with the @event decorator.

Since the event decorator is being imported with the event keyword

Example:

import event from "@ui5/webcomponents-base/dist/decorators/event.js";

Using the keyword "event" as a paramater for our handlers leads to a collision between the parameter and the @event decorator.

// Before ( which would lead to a name collision now )

_onfocusin(event: FocusEvent) {
	const target = event.target as ProductSwitchItem;
	this._itemNavigation.setCurrentItem(target);
	this._currentIndex = this.items.indexOf(target);
}

In order to avoid this and keep consistency, we made a decision to name the parameters in our handlers "e" instead.

// After

_onfocusin(e: FocusEvent) {
	const target = e.target as ProductSwitchItem;

	this._itemNavigation.setCurrentItem(target);
	this._currentIndex = this.items.indexOf(target);
}


2. Initialize all class members directly in the constructor.

When creating classes, initialize all class members directly in the constructor, and not in another method, called in the constructor. This is to ensure that TypeScript understands that a class member will be always initialized, therefore is not optional.

Example:

// Before 

class UI5Element extends HTMLElement {
	constructor() {
		super();
		this._initializeState();
	}

	_initializeState() {
		const ctor = this.constructor;
		this._state = { ...ctor.getMetadata().getInitialState() };
	}
}

Before the change, we used to initialize _state in the _initializeState function. However, after the refactoring to TypeScript, we must do it directly in the constructor, otherwise it is not recognized as always initialized.

// After

class UI5Element extends HTMLElement {
	_state: State,

	constructor() {
		super();
		const ctor = this.constructor as typeof UI5Element;
		this._state = { ...ctor.getMetadata().getInitialState() };
	}
}


3. Create types for the Event Details.

To enhance the quality and readability of our code, we should establish specific types for the Event Details. This approach will clearly define the required data for an event and optimize its usage. Without well-defined EventDetail types, we may also encounter naming conflicts between similar event names in various components, leading to potential errors. Implementing EventDetail types will effectively resolve this issue.

  • 3.1 How should we structure the name of our EventDetail type ?

    • In order to be consistent within our project, the latest convention about how we name our EventDetail types is by using the following pattern:
// File: DayPicker.ts

// The pattern is 
// <<WebComponentName><EventName><EventDetail>>

type DayPickerChangeEventDetail = {
	dates: Array<number>,
	timestamp?: number,
}

class DayPicker extends CalendarPart implements ICalendarPicker {
	...
	_selectDate(e: Event, isShift: boolean) {
		...
		this.fireEvent<DayPickerChangeEventDetail>("change", {
			timestamp: this.timestamp,
			dates: this.selectedDates,
		});
	}
}


4. Use the syntax of Array<T> instead of T[].

While both notations work the same way, we have chosen to utilize the Array<T> notation, as opposed to T[], to maintain consistency with the notations for Map<> and Record<>.

For example:

// Instead of
let openedRegistry: RegisteredPopUpT[] = [];

// We’ll use
let openedRegistry: Array<RegisteredPopupT> = [];


5. Use enums over object literals.

Instead of using object literals, we have opted for enums to enhance type safety and maintainability. The use of enums provides compile-time type safety, reducing the potential for errors and making the code easier to manage. It is also important to note that all types in our “types” folder are already represented as enums.

Example:

// File: ColorConvension.ts

// Instead of 

const CSSColors = {
	aliceblue: "f0f8ff",
	antiquewhite: "faebd7",
	aqua: "00ffff",
	aquamarine: "7fffd4",
}

// We’ll use 

enum CSSColors {
	aliceblue = "f0f8ff",
	antiquewhite = "faebd7",
	aqua = "00ffff",
	aquamarine = "7fffd4",
}


6. Use the "keyof typeof" syntax when accessing dynamically objects with known keys.

When accessing dynamically objects with known keys, always use the "keyof typeof" syntax for improved accuracy.

Example:

// File: ColorConvension.ts

enum CSSColors {
	aliceblue = "f0f8ff",
	antiquewhite = "faebd7",
	aqua = "00ffff",
	aquamarine = "7fffd4",
}


const getRGBColor = (color: string): ColorRGB => {
	...
	if (color in CSSColors) {
		color = CSSColors[color as keyof typeof CSSColors];
	}

	return HEXToRGB(color);
};

# In the cases where the keys are unknown or uncertain we use the Record<K, T> notation instead of the {[key]} notation.
In short, Record<K, T> is a TypeScript notation for describing an object with keys of type K and values of type T.

Example:

// File: UI5ElementMetadata.ts
...
type Metadata = {
	tag: string,
	managedSlots?: boolean,
	properties?: Record<string, Property>,
	slots?: Record<string, Slot>,
	events?: Array<object>,
	fastNavigation?: boolean,
	themeAware?: boolean,
	languageAware?: boolean,
};


7. Do not use “any”, unless absolutely necessary.

The "any" type, while powerful, can be a dangerous feature as it instructs the TypeScript compiler to ignore type checking for a specific variable or expression. This can result in errors and make the code more complex to understand and maintain. Our ESLint usually takes care of this by enforcing best practices and avoiding its usage.


TypeScript specific guidelines


1. When to use “import type” ?

The import keyword is used to import values from a module, while import type is used to import only the type information of a module without its values. This type information can be used in type annotations and declarations.

For clarity, it is recommended to keep type and non-type imports on separate lines and explicitly mark types with the type keyword, as in the following example:

// This line

import I18nBundle, { getI18nBundle, I18nText } from "@ui5/webcomponents-base/dist/i18nBundle.js";

// Should be split into 

// Named export (function) used into the the component class
import { getI18nBundle } from "@ui5/webcomponents-base/dist/i18nBundle.js";

// Default export used into the the component class.
// I18nBundle is a class constructor, but in the current example it's used 
// as a type for a variable to which the class will be assinged.
import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";

// named type export
import type { I18nText } from "@ui5/webcomponents-base/dist/i18nBundle.js";


2. When should we use the "!" operator in component’s file ?

The ! operator in TypeScript is used to indicate that a value is not null or undefined in situations where the type checker cannot determine it.

It is commonly used when working with the this.getDomRef() and this.shadowRoot properties in our web components. The return types of these properties, HTMLElement | null and ShadowRoot | null, respectively, are marked with null because there may be instances when these values are not yet available.

This operator can also be used in other situations where TypeScript does not understand the framework’s lifecycle, for example, when working with custom elements.

In short, the ! operator is a useful tool for ensuring that a value is not null or undefined in cases where the type checker cannot determine this on its own.

For example:

import UI5Element from "sap/ui/core/Element";

class Example extends UI5Element {
	testProperty?: string;

	onBeforeRendering() {
		this.testProperty = "Some text";
	}

	onAfterRendering() {
		// here TypeScript will complain about that the testProperty may be undefined
		// in order of its definition and because it doesn't understand the framework's lifecycle
		const varName: string = this.testProperty!;
	}
}


3. Usage of Generics.

Generics in TypeScript help us with the creation of classes, functions, and other entities that can work with multiple types, instead of just a single one. This allows users to use their own types when consuming these entities.

In the migration to TypeScript, generic functions have been added to the UI5Element, and a common approach for using built-in generics has been established.

Our first generic function is the fireEvent function, which uses generics to describe the event details and to check that all necessary details have been provided. The types used to describe the details provide helpful information to consumers of the event as explained above.

For example:

fireEvent<EventDetail>("click")


The use of custom events as the type for the first argument of an event handler can result in TypeScript complaining about unknown properties in the details. By using generics and introducing a type for event details, we can tell Typescript which parameters are included in the details, and thus avoid these complaints.

handleClick(e: CustomEvent<EventDetail>)

The second use of generics is in the querySelector function. It allows us to specify a custom element return type, such as “List,” while retaining the default return type of T | null. This allows for more precise type checking and better understanding of the expected return value.

It’s important to note that casting the returned result will exclude “null.” Additionally, if the result is always in the template and not surrounded by expressions, the “!” operator can be used.

async _getDialog() {
	const staticAreaItem = await this.getStaticAreaItemDomRef();
	return staticAreaItem!.querySelector<Dialog>("[ui5-dialog]")!;
}

The third use case for generics is with the getFeature function. This function enables us to retrieve a feature, if it is registered. It is important to note that getFeature returns the class definition, rather than an instance of the class. To use it effectively, the typeof keyword should be utilized to obtain the class type, which will then be set as the return type of the function.

	getFeature<typeof FormSupportT>("FormSupport")


4. Managing Component Styles with CSSMap and ComponentStylesData in the Inheritance Chain

To resolve inheritance chain issues, we introduced two types that can be used in the components. All components have implemented a static get styles function that returns either an array with required styles or just the component styles without an array. However, depending on the inheritance chain, TypeScript may complain about wrong return types, without considering that they will be merged into a flat array in the end.

// File: ListItem.ts

static get styles(): ComponentStylesData {
	return [ListItemBase.styles, styles];
}


5. Resolving the this type error with TypeScript.

By default in Strict Mode, the type of this is explicitly any. When used in a global context function, as in the example, TypeScript will raise an error that this has an explicit type of any. To resolve this, you can add this as the first argument to the function and provide its type, usually the context in which the function will be used.

type MyType = {
	base: number;
	pow: (exponent: number) => number;
};

function pow(this: MyType, exponent: number) {
	return Math.pow(this.base, exponent);
}

const basePow: MyType = {
	base: 2,
	pow,
};