Visual Studio Code, SAPUI5 Extension now supports Typescript

After couple of months of work and refactoring, the SAPUI5 Extension is finally working with Typescript. There are a lot of cons to use TS, however, UI5 has some inconveniences. Together with this announcement I will give some advice and explain new TS related commands, which are introduced in v0.15.0.

One of the painful moments is type casting for controls.

import Button from "sap/m/Button";
const oButton = this.byId("idButtonPost") as Button;

This can transform into something even less beautiful:

((this.byId("idListOrders") as List).getBinding("items") as JSONListBinding).filter(aFilters);

Spoiler alert: in the end of this topic it will transform to:

this.byId("idListOrders").getBinding<JSONListBinding>("items").filter(aFilters);

That’s not exactly what any developer would like to do. There were questions to SAP that it would be nice to use generics, however, the response was that Microsoft doesn’t recommend using generics in such way.

The solution which came into my mind allows to use byId method and have typing in place without casting.

In order to make the idea work, it’s necessary to implement couple of things:

  1. Extend byId method in BaseController
  2. Create mapping from [Control id] to [class of the Control]
  3. Extend BaseController, pass the mapping from previous point

Here comes the first and second parts:

import UI5Element from "sap/ui/core/Element";
import Controller from "sap/ui/core/mvc/Controller"; /** * @namespace ui5.typescript.helloworld.controller */
export default class BaseController< ViewFragmentIds extends Record<string, UI5Element>
> extends Controller { byId<T extends keyof ViewFragmentIds>(sId: T): ViewFragmentIds[T] { return super.byId(sId as string) as ViewFragmentIds[T]; }
} 

Translation to english:

byId method from the parent is overridden in BaseController. Now it uses generic named ViewFragmentIds, which extends Record<string, UI5Element>. Example of such type could be:

import Button from "sap/m/Button";
import List from "sap/m/List"; export type MasterView = { idListOrder: List; idButtonPost: Button;
}

Where field has type string, and value of the field is UI5Element.

byId method now accepts sId, which is restricted to the keys of ViewFragmentIds (idListOrder, idButtonPost).

The implementation is done, and now the third point is left. Lets assume we have Master.view.xml

<mvc:View controllerName="ui5.typescript.helloworld.controller.Master" xmlns:mvc="sap.ui.core.mvc" xmlns="sap.m" displayBlock="true" height="100%" busyIndicatorDelay="0"
> <Page> <List id="idListOrders"/> <Button id="idButtonPost" type="Accept" text="Post" /> </Page>
</mvc:View>

And Master.controller.ts

import BaseController from "./BaseController";
import { MasterView } from "./ViewFragmentIds"; /** * @namespace ui5.typescript.helloworld.controller */
export default class Master extends BaseController<MasterView> { onInit() { }
}

In order to make byId method know which ids are used and which class does every id map to, It is necessary to pass id to class mapping type (MasterView) to BaseController. MasterView type from previous point is used here.

The result looks as follows:

    1. Completion items work
    2. Type checking works
    3. Automatic type assignment works

Multiple views/fragments

Let’s take an example when there is dependent fragment added, e.g. any Value Help Dialog.

Let’s assume that is has sap.m.Table which we can access using byId as well. In such case it’s possible to merge types by using &. (MasterView & OrderItemValueHelpDialogFragment)

Types:

import Button from "sap/m/Button";
import List from "sap/m/List";
import Table from "sap/m/Table"; export type MasterView = { idListOrder: List; idButtonPost: Button;
}; export type OrderItemValueHelpDialogFragment = { idTableOrderItems: Table;
};

Master.controller.ts

import BaseController from "./BaseController";
import { MasterView, OrderItemValueHelpDialogFragment } from "./ViewFragmentIds"; /** * @namespace ui5.typescript.helloworld.controller */
export default class Master extends BaseController<MasterView & OrderItemValueHelpDialogFragment> { onInit() { const oList = this.byId("idListOrder"); const oTable = this.byId("idTableOrderItems"); }
}

Generating types

The only problem left is adjusting types. That’s where new command comes in:

UI5: (TS) Generate types for XML files (id to class mapping)

This command generates the types for you. Just copy them and place in any needed place.

As TS works the best when there are no abstract types as object, any etc, it comes very handy when everything is typed.

Let’s assume that the project reads data from OData service and puts it into JSONModel, and when there is a necessity to get data from it, would be nice to have types for that. JSONModel->getProperty method returns any, which basically gives us zero information.

There are multiple things to implement in order to make everything look beautiful:

  1. Making getModel method to recognize model class by its name
    1. Create model name to model class mapping and call it e.g. ManifestModels.d.ts
      import JSONModel from "sap/ui/model/json/JSONModel";
      import ODataModel from "sap/ui/model/odata/v2/ODataModel"; export type ManifestModels = { S4HANAModel: ODataModel; MasterModel: JSONModel;
      }​
    2. Create e.g. OverrideStandard.d.ts file and add a signature to getModel method
      import { ManifestModels } from "./ManifestModels";
      declare module "sap/ui/base/ManagedObject" { export default interface ManagedObject { getModel<T extends keyof ManifestModels>(sModelName: T): ManifestModels[T]; }
      }
      ​

      Result: getModel method now understands, that “MasterModel” is JSONModel:

  2. Add a signature to getProperty method of the JSONModel, so it could accept generic type, it could be added in the same OverrideStandard.d.ts file
    import Context from "sap/ui/model/Context";
    declare module "sap/ui/model/json/JSONModel" { export default interface JSONModel { getProperty<T>(sPath: string, oContext?: Context): T; }
    }
  3. Defining types for OData entities. Create e.g. ODataEntities.d.ts file and add e.g. Order type or interface
    export interface Order { OrderID: string; Description: string; OrderItemQuantity: number;
    }​
  4. Get the property from the model and pass the type as a generic

Congratulations! Now there is fully functioning getModel method, getProperty from JSONModel which accepts generic type and fully typed data.

Generating types

And again, the same problem appears with OData entities. I doubt that anyone would like to transform OData metadata to TS types manually. That’s where new command comes in and does the work for you.

UI5: (TS) Generate interfaces for OData entities

To use that command either 

  1. Open metadata.xml in the editor and execute command
  2. Or execute command, paste link to metadata. Command will prompt for user id and password.

Copy the result in ODataEntities.d.ts and use the typing everywhere.

Generating of model name to model class mapping is not currently implemented, however, I do plan to create a npm module which will generate everything.

Additional information about entity set data is generated as well.

Example:

export interface CatalogKeys { id: string;
} export interface Catalog extends CatalogKeys { type: string; domainId: string; scope: string; baseUrl: string; /** @description Counter */ chipCount: string; Chips?: { results: Chip[] };
} export type EntitySets = { "Catalogs": { keys: CatalogKeys; type: Catalog; typeName: "Catalog"; };
}

This information can be used for restricting CRUD/FI operations. Example of ODataModel extension:

import ODataModel from "sap/ui/model/odata/v2/ODataModel";
import { EntitySets } from "../util/ODataEntities"; type TCreateParameters = Parameters<typeof ODataModel.prototype.create>["2"]; /** * @namespace ui5.typescript.helloworld.control */
export default class CustomODataModel extends ODataModel { createAsync<EntitySet extends keyof EntitySets & string>( sPath: `/${EntitySet}`, mData: EntitySets[EntitySet]["keys"], mOptions: YCreateParameters = {} ): Promise<EntitySets[EntitySet]["type"]> { return new Promise((resolve, reject) => { mOptions.success = resolve; mOptions.error = reject; this.create(sPath, mData, mOptions); }); }
}

Usage:

private async _createCatalog() { const oODataModel = this.getView().getModel("CustomODataModel"); const mCreatedCatalog = await oODataModel.createAsync("/Catalogs", { id: "123", }); }

Two major things introduced here:

  1. async create method, which means that it can be awaited
  2. strictly typed entity sets
  3. automatic return type detection
  4. Automatic entity set based type detection of the object which should be passed to create method

I found it useful to add generics to other classes as well.

getBinding

declare module "sap/ui/base/ManagedObject" { export default interface ManagedObject { getBinding<T extends Binding = Binding>(sName: string): T; }
} //example of usage
this.byId("idTableOrders").getBinding<JSONListBinding>("items").filter(aFilters);

getObject/getProperty

declare module "sap/ui/model/Context" { export default interface Context { getObject<T>(sPath?: string | undefined, mParameters?: object | undefined): T; getProperty<T>(sPath: string): T; }
} //example of usage
const oSelectedOrderItem = this.byId("idTableOrders").getSelectedItem();
const mOrder = oSelectedOrderItem.getBindingContext().getObject<Order>();
const sOrderId = oSelectedOrderItem.getBindingContext().getProperty<string>("OrderID");

Router

There is a way to make router methods strictly typed.

  1. Install ts-json-as-const package
    npm install ts-json-as-const
  2. run
    npx ts-json-as-const ./src/manifest.json

    This will generate manifest.json.d.ts

  3. Create e.g. util/RouterTypes.d.ts and add
    import * as Manifest from "../manifest.json"; const aRoutes = Manifest["sap.ui5"].routing.routes.map((route) => route.name);
    const aTargets = Manifest["sap.ui5"].routing.routes.map((test) => test.name);
    export type Routes = typeof aRoutes[number];
    export type Targets = typeof aTargets[number]; export type RouterArgs = { [key: Routes]: object; Master: { args: {}; }; Detail: { args: { documentId: string; }; };
    }; export type MasterArgs = RouterArgs["Master"]["args"];
    export type DetailArgs = RouterArgs["Detail"]["args"];
    

    Routes are “Master” | “Detail”
    Targets are “Master” | “Detail”
    RouterArgs are arguments for Routes, which will be used in navTo

Result:

I do recommend creating custom router and overriding navTo method. In that case no new signature to navTo method will be added but rewritten instead. As a result, the code will not compile if wrong route/target or arguments are passed.

If there will be more ideas on how to override standard in order to make it more convenient/typed, I will update this thread and add them here.

Hopefully this will help to make the UI5 projects more reliable and convenient to develop. Looking forward comments, especially if anybody would really use anything listed above. Happy coding!