Skip to main content

TypeScript Development

Since 1.11.0 we migrated the framework and all components to TypeScript. In addition to the pure code migration, we introduced a new format of component metadata definition leveraging TypeScript decorators.

Component Metadata​

Decorators​

We use decorators to describe the components' metadata. Here is the 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";

Class decorators​

The class decorators are used just before the component's class declaration and applied to the constructor of the class to describe the component:

  • @customElement - to define class-related metadata entities: tag, renderer, template, styles, dependencies and more.
import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js";
  • @event - to define the events, fired by the component
import event from "@ui5/webcomponents-base/dist/decorators/event.js";

Example:

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

}

Example: @customElement can be used to define all class-related metadata entities:

@customElement({
tag: "my-element-name",
languageAware: true,
themeAware: true,
fastNavigation: true,
renderer: Renderer,
styles: MyElementStyles,
template: MyElementTemplate,
staticAreaStyles: MyStaticAreaStyles,
staticAreaTemplate: MyStaticAreaTemplate,
dependencies: [ComponentA, ComponentB],
})
class MyElement extends UI5Element {

}

Note: the static get render() that we use when developing in JavaScript (still supported for backward compatibility) is replaced with renderer in the @customElement decorator.

Property decorators​

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

  • @property- to define components' properties
import property from "@ui5/webcomponents-base/dist/decorators/property.js";
  • @slot - to define components' slots
import slot from "@ui5/webcomponents-base/dist/decorators/slot.js";

Defining properties (@property)​

The @property decorator has a single parameter of type object with the following fields to describe a component property:

  • type?: BooleanConstructor | StringConstructor | ObjectConstructor | DataType
  • validator?: DataType,
  • defaultValue?: PropertyValue,
  • noAttribute?: boolean,
  • multiple?: boolean,
  • compareValues?: boolean,

The fields are explained in detail in the Deep dive and best practices article.

Example: "String properties with no specific default value" - we skip all settings as String is the default type and empty string is the default value.

/**
* 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;

Example: "Properties with enumerated values" - we use enum for both the TypeScript class member and the property metadata in the decorator

/**
* Defines the component design.
*
* @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;

Example: 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).

Example: TypeScript types (string, boolean) are used for TypeScript class members, and Javascript constructors (String, Boolean) for the metadata settings (as before)

@property({ type: Boolean })
hidden!: boolean;

Usage of @name in properties' documentation​

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.

Usage of ? and !​

Use ? for all metadata properties that may be undefined or null, and ! for all other metadata properties. As a rule of thumb:

  • Boolean properties are always defined with ! as they are always false by default
@property({ type: Boolean })
interactive!: boolean;
  • String properties are always defined with ! as they are empty string by default, unless you specifically set defaultValue: undefined (then use ?)
@property()
text!: string;
@property({ defaultValue: undefined })
target?: string;
  • properties with validator set, should be always defined with ? as they are undefined by default, unless you specify a truthy default value.
@property({ validator: Float })
width?: number

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: we use ! to instruct the TypeScript compiler that the variable will be initialized with a default value different than null and undefined, since the TypeScript compiler does not know about the component lifecycle and the fact that the framework will initialize the design class member.

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 parameter 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);
}

To avoid this and keep consistency, we decided 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 ?

    • 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 dynamically accessing objects with known keys.

When dynamically accessing 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 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 of 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) called into the component class
import { getI18nBundle } from "@ui5/webcomponents-base/dist/i18nBundle.js";

// Default type export.
// Although I18nBundle is a class, it's used as a type of a variable.
import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";

// Named type export, used as a type of a variable.
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 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.

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 a 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,
};