Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: experimental TypeSafeJSONModel #706

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
18 changes: 18 additions & 0 deletions packages/ui5-types-typedjsonmodel/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
module.exports = {
root: true,
env: {
browser: true,
es6: true,
node: true,
},
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended-requiring-type-checking"],
ignorePatterns: [".eslintignore.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: ["./tsconfig.json"],
tsconfigRootDir: __dirname,
sourceType: "module",
},
plugins: ["@typescript-eslint"],
};
11 changes: 11 additions & 0 deletions packages/ui5-types-typedjsonmodel/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Change Log

All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.

# 0.2.0 (2023-03-31)


### Features

* experimental TypeSafeJSONModel ([0f18623](https://github.com/ui5-community/ui5-ecosystem-showcase/commit/0f18623222430f56c7ee340cd010b80a0b816c2f))
127 changes: 127 additions & 0 deletions packages/ui5-types-typedjsonmodel/Readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Typed JSONModel

This utility is a TypeScript library designed to enhance the development experience for UI5 projects that utilize JSONModel. UI5 is an open-source JavaScript UI library maintained by SAP. JSONModel is a core concept in UI5, providing a way to bind data to UI controls.

The Typed JSONModel utility adds type safety and improved development capabilities when working with JSONModel in your OpenUI5 projects. By leveraging TypeScript's static typing features, this utility helps catch type-related errors early and provides enhanced code completion and documentation in your code editor.

## Features

- 🔒 **Type Safety**: The utility enables you to define TypeScript types for your JSONModel data, ensuring that the data structure conforms to the specified types at compile-time.
- 💡 **Code Completion**: With the type definitions in place, your code editor can provide accurate autocompletion suggestions and documentation for the JSONModel data.
- 🚀 **Improved Development Experience**: By eliminating potential runtime type errors, you can write more reliable and maintainable code, resulting in a smoother development experience.
- 🔌 **Easy Integration**: The utility can be easily integrated into your existing OpenUI5 projects by following the installation and usage instructions provided below.

Feel free to leverage these features to enhance your UI5 project development!

## Installation

1. If you are not already using TypeScript in your UI5 project, it is recommended to check out [UI5 & TypeScript](https://sap.github.io/ui5-typescript/) first.

1. Install the utility as a development dependency in your project:

```bash
npm install ui5-types-typedjsonmodel --save-dev
```

1. In order to prevent runtime overhead, or bundling headaches the Typed JSONModel utility is only used in TypeScript by appling it's type over a ordinary JSONModel. To do this we need to write the following utility function:

```typescript
// webapp/model/model.ts
import type { TypedJSONModel, TypedJSONModelData } from "ui5-types-typedjsonmodel/lib/TypedJSONModel";

export function createTypedJSONModel<T extends TypedJSONModelData>(oData: T, bObserve?: boolean | undefined) {
return new JSONModel(oData, bObserve) as TypedJSONModel<T>;
}
```

## Usage

1. Import the `createTypedJSONModel` method

```typescript
// Test.controller.ts
import { createTypedJSONModel } from "../model/models";

```

1. Create a new model inside your controllers class

```typescript
// Test.controller.ts
export default class Test extends Controller {
myModel = createTypedJSONModel({
foo: {
bar: 'bar',
baz: 3,
},
messages: [
{
content: 'Hello World',
}
]
});
}
```

1. Bind the model to the view

```typescript
// Test.controller.ts
export default class Test extends Controller {
// ...

onInit() {
this.getView()?.setModel(this.myModel);
}
}
```

1. Read/Write to the model

```typescript
// TypeSafe Reads
const foo = this.myModel.getProperty('/foo'); // foo has the type { bar: string, baz: number }
const bar = this.myModel.getProperty('/foo/bar'); // bar has the type string
const messages = this.myModel.getProperty('/messages'); // messages has the type { world: boolean }[]
const firstMessage = this.myModel.getProperty('/messages/0'); // firstMessage has the type { content: string }
const firstMessageContent = this.myModel.getProperty('/messages/0/content'); // firstMessageContent has the type string

// TypeSafe Writes
this.myModel.setProperty('/foo', { bar: 'any string', baz: 10 }); // OK
this.myModel.setProperty('/foo', { bar: 'any string', baz: '3' }); // Not OK, baz is a string not a number
this.myModel.setProperty('/foo', { bar: 'test' }); // Not OK, baz is missing
this.myModel.setProperty('/messages/1', { content: 'New Message' }); // OK
this.myModel.setProperty('/foo/baz', 123); // OK
this.myModel.setProperty('/foo/bazz', 123); // Not Ok, property bazz does not exist on type foo
this.myModel.setProperty('foo/baz', 123); // Not OK, missing leading slash in path
this.myModel.setProperty('/foo/test', 123); // Not OK, property test does not exist on type foo
```

Note that you can also explicitly specify the type of the model content if you don't want to infer it from the default values:

```typescript
// Test.controller.ts

type MyModelContent = {
items: {
status: "success" | "failure";
message: string;
}[];
}

export default class Test extends Controller {
myModel = createTypedJSONModel<MyModelContent>({
items: [],
});

// ...
}
```

## Limitations

- To prevent infinitely large types, the depth of recursive objects is limited to 10 by default
- Access to array items via an index in the property string aren't checked even if the array is defined as a touple
- Autocomplete for array indicies might not work in all cases
- Recursive types as model definitions may not work in all cases
- TypeScripts `readonly` attribute may not work as expected in all cases
138 changes: 138 additions & 0 deletions packages/ui5-types-typedjsonmodel/lib/TypedJSONModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// eslint-disable-file

/**
* This file contains the type safe facade "TypedJSONModel" for "JSONModel".
*
* Since TypedJSONModel is only a type it has no runtime differences or overhead compared to the JSONModel.
*
* The JSONModel class is used to store data which has to be synchronized with the view. JSONModel mainly uses the
* getProperty() and setProperty() methods to access the stored values. To specify which property should be
* returned the JSONModel uses the names of the object keys and '/' as a separator to access nested properties.
* E.g. if we have the object <code>{ foo: 'FOO', bar: { baz: 'BAZ' }}</code> we could use '/foo' to access
* the string "FOO", '/bar' to access the object <code>{ baz: 1 }</code> or '/bar/baz' to access the string
* "BAZ".
*
* The purpose of TypedJSONModel is to add typechecking at compile time. This means that the returned
* properties have the correct type, instead of the <code>any</code> type that the JSONModel returns by
* default. Additionally, accessing non-existent properties will cause type errors at compile time.
*/

import JSONModel from "sap/ui/model/json/JSONModel";

import type Context from "sap/ui/model/Context";

//#region Configuration
/**
* You can safely overwrite this value to configure the TypedJSONModel
*/
interface TypedJSONModelConfig {
/**
* The maximum recursion depth used during path discovery.
*
* Only values between 0 and 20 are allowed. You can safely overwrite this value.
*/
maxDepth: 10;
}
//#endregion

//#region Helper types
/**
* Alias for ease of use
*/
type RecursionMaxDepth = TypedJSONModelConfig["maxDepth"];

/**
* Array used to limit recursive calls
*
* This array works as a counter since for e.g. depth = 5 the value of RecursionLimiterValues[5] is 4
*/
type RecursionLimiterValues = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20];

/**
* Exclude the symbol type from a given type union
*/
type NoSymbols<T> = T extends symbol ? never : T;

//#endregion

//#region JSON Types

/**
* JSON safe value
*/
type JSONScalar = string | boolean | number | Date | null | undefined;

/**
* JSON safe array
*/
type JSONArray = (JSONScalar | JSONObject)[];

/**
* JSON safe object
*/
type JSONObject = {
[x: string]: JSONScalar | JSONObject | JSONArray;
};

/**
* Type to represent JSONModels content
*
* We disallow scalar values as roots since UI5 doesn't allow it
*/
type JSON = JSONArray | JSONObject;

//#endregion

//#region Generate string literal union of all valid paths for a given object

type PathType<D extends JSON> =
// We use this intermediary helper for convenience
PathTypeHelper<D, NoSymbols<keyof D>, RecursionMaxDepth>;

type PathTypeHelper<D, K extends NoSymbols<keyof D>, DEPTH extends RecursionLimiterValues[number]> = {
done: never;
recur: D extends JSONObject // Objects
? K extends string | number
? `/${K}${"" | PathTypeHelper<D[K], NoSymbols<keyof D[K]>, RecursionLimiterValues[DEPTH]>}` // Recursive call
: never // shouldn't happen
: D extends JSONArray // Arrays
? K extends number
? `/${K}${"" | PathTypeHelper<D[K], NoSymbols<keyof D[K]>, RecursionLimiterValues[DEPTH]>}` // Recursive call
: never // shouldn't happen
: never; // JSON Scalar
}[DEPTH extends -1 ? "done" : "recur"];

//#endregion

//#region Lookup property type of given object for a given string literal

// Value Type - lookup type of data specified by the path
type ValueType<D extends JSON, P extends PathType<D>> =
// We use this intermediary helper for convenience
ValueTypeHelper<D, P>;

type ValueTypeHelper<D, P extends string> = D extends JSONObject | JSONArray
? P extends `/${infer KP extends NoSymbols<keyof D>}` // Extract string
? D[KP] // Return type if we have reached a property
: P extends `/${infer KN extends NoSymbols<keyof D>}/${infer REST extends string}` // Nested object
? ValueTypeHelper<D[KN], `/${REST}`> // Recursive call (we need to add a / since the template "REST" doesn't include it above)
: never
: never;
//#endregion

//#region Typesafe JSON Model

/**
* Type safe facade over UI5's JSONModel
*
* @see JSONModel
*/
export interface TypedJSONModel<D extends JSON> extends JSONModel {
constructor: (oData: D, bObserve?: boolean | undefined) => this;
getProperty: <P extends PathType<D>>(sPath: P, oContext?: Context | undefined) => ValueType<D, P>;
setProperty: <P extends PathType<D>, V extends ValueType<D, P>>(sPath: P, oValue: V, oContext?: Context | undefined, bAsyncUpdate?: boolean | undefined) => boolean;
setData: (oData: D, bMerge?: boolean | undefined) => void;
}

export type TypedJSONModelData = JSON;
//#endregion
21 changes: 21 additions & 0 deletions packages/ui5-types-typedjsonmodel/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "ui5-types-typedjsonmodel",
"version": "0.2.0",
"description": "UI5 TypedJSONModel",
"private": true,
"author": "Marc Popescu-Pfeiffer",
"license": "MIT",
"types": "index.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/ui5-community/ui5-ecosystem-showcase.git",
"directory": "packages/ui5-types-typedjsonmodel"
},
"scripts": {
"lint": "eslint ."
},
"devDependencies": {
"@types/openui5": "1.112.0",
"typescript": "^5.0.3"
}
}
14 changes: 14 additions & 0 deletions packages/ui5-types-typedjsonmodel/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2022",
"module": "es2022",
"moduleResolution": "node",
"skipLibCheck": true,
"allowJs": true,
"strict": true,
"strictPropertyInitialization": false,
"rootDir": "./",
"baseUrl": "./"
},
"include": ["./lib/**/*"]
}
26 changes: 26 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion showcases/ui5-tsapp-simple/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"typescript": "^5.1.6",
"ui5-middleware-livereload": "workspace:^",
"ui5-tooling-modules": "workspace:^",
"ui5-tooling-transpile": "workspace:^"
"ui5-tooling-transpile": "workspace:^",
"ui5-types-typedjsonmodel": "workspace:^"
}
}
Loading
Loading