What we accomplished here uses composition.
Problems:
- properties are public
- Sync, Eventing and Attributes are hard types. We should use interfaces to components can be swapped in and out.
- Make general functionalities reusable
Composition worse problem is nested properties.
Thus, let’s create a class Model that solves some of these problems. In order to do so, this class Model will have private properties that:
1) can hide the objects that deal with inner workings of our class.
2) use interfaces that will allow us to switch out components. Thus, it’s not hard coded. As long as our components adhere to those interfaces, we’re good to go.
3) We will also use generic type T so that the compiler will recognize the attributes and methods of any objects that we will pass in. That way, there will be type data available and intellisense can show us what we need.
Interfaces for the components
First we create interfaces for the components that will be used in Models.
This is so that as long as other components conform to these interfaces, we can interchange them.
For example, we need a component that represents Syncing. So we create interface Sync where you must implement:
1) fetch that takes in an id of type number and returns AxiosPromise.
2) save that takes in data of generic type T and returns AxiosPromise
1 2 3 4 |
interface Sync<T> { fetch(id:number):AxiosPromise; save(data: T): AxiosPromise; } |
We need a component that takes care of settings and getting local attributes, so we create interface ModelAttributes where:
1) get a certain type, where the type exists in keys of generic type T
2) getAll attributes where we return the whole object
1 2 3 4 5 6 |
// use T where we want to use type interface ModelAttributes<T> { set(value: T): void; get <K extends keyof T>(key: K): T[K]; getAll(): T; } |
Finally, we create an interface Events, where we force the component to implement:
1) on, with string param eventName, and a Callback so that the system can update you.
2) trigger, with string param eventName
1 2 3 4 5 |
type Callback = () => void; interface Events { on(eventName: string, callback: Callback); trigger(eventName: string): void; } |
But first, let’s look at what T means and how it is used.
Model class
We create Model class with generic type T.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
interface ModelMustHave { id?: number; name? :string; } export class Model<T extends ModelMustHave> { constructor( private attributes: ModelAttributes<T>, private events: Events, private sync: Sync<T>, ) { } .... } |
We create private properties that has interface types T, which forces our data to have certain properties and to be constructed in a certain way. Notice that T acts as the generic type for some interfaces (ModelAttributes, Sync) in our properties.
Now, we create our Class that extends Model so all models can share general functionalities.
Let’s first declare a type that we can use for generic type T.
Declaring an interface for generic type T
ref – http://chineseruleof8.com/code/index.php/2021/12/06/generics/
We create an interface that act as a generic type.
1 2 3 4 5 |
export interface UserProps { id?: number; // declare id of type number name?: string; age?: number; } |
Then we create a class User where it extends form Model.
This means User automatically takes on all attributes and methods of Model.
This is because those attributes and methods of Model are very general which we would to exist for any future classes that deals with Models. i.e User, Buildings, Animals….etc.
1 2 3 4 5 6 7 8 9 |
export class User extends Model <UserProps>{ static buildUser(attrs: UserProps): User { return new User( // calls parent class Model's constructor new Attributes<UserProps>(attrs), // ModelAttributes<T> new Eventing(), // Events new ApiSync <UserProps>(rootUrl) // Sync<T> ); } } |
Now let’s look at the private attributes.
ModelAttributes is an interface that says you must implement these functions, for whatever generic type T these objects are.
1 |
private attributes: ModelAttributes<UserProps> = new Attributes<UserProps>(attrs) |
We instantiate class Attributes, which conforms to ModelAttributes by having methods set, getAll, and get
Events is an interface that says you must implement these functions.
1 |
private events: Events = new Eventing() |
We instantiate class Eventing, which conforms to by having methods on, trigger
Sync is an interface that says you must implement these functions, for whatever generic type T these objects are.
1 |
private sync: Sync<UserProps> = new ApiSync<UserProps>(attrs) |
We instantiate class ApiSync, which conforms to by having methods fetch, save, and get
Functions with generic type T
Look at the functions in our classes Attribute and ApiSync:
1 2 3 |
getAll(): T { return this.data } |
or:
1 2 3 4 |
save(data: T): AxiosPromise { const { id, name } = data return id ? axios.put(`${this.rootUrl}/` + id, data) : axios.post(this.rootUrl, data) } |
Notice T. T simply means generic interface. In our case of User extends Model, we have declared T to be UserProps like so:
1 2 3 4 5 6 7 8 |
export class User extends Model <UserProps>{ static buildUser(attrs: UserProps): User { return new User( // calls parent class Model's constructor new Attributes<UserProps>(attrs), ... } } } |
Look at
1 |
new Attributes<UserProps>(attrs) |
Attributes’ constructor, we pass in an object that conforms to UserProps (has optional properties id, name, and age):
1 2 3 4 |
{ id: 1, name: "ricky" } |
gets passed into Attributes’ constructor:
1 2 3 4 |
User.buildUser({ id: 1, name: "ricky" }); |
where
1 2 3 4 5 |
static buildUser(attrs: UserProps): User { return new User( // calls parent class Model's constructor new Attributes<UserProps>(attrs), .. ... |
Attributes’ getAll() returns UserProps, which we can then pass into
Sync’s save():
1 2 3 |
save(data: T): AxiosPromise { ... } |
Just remember to declare a generic type T (i.e UserProps) when we extend User from Model
Constraints on Generic Type
Now, take a look at ModelMustHave:
The generic type T extends from interface ModelMustHave.
This means that the allowed optional attributes presented in UserProps are limited to ModelMustHave, which is:
-id
-name
This limit comes in handy when we’re trying to implement get, where we want to limit the kind of property strings to allow in the key parameter.
Let’s take a detailed look:
1 |
get <K extends keyof T>(key: K): T[K]; |
keyof T – http://chineseruleof8.com/code/index.php/2021/12/01/in-typescript/
If it was just T, our union would be “id” | “name” | “age”
In our case, T extends ModelMustHave for User
So hence, only “name” and “id” will work because ModelMustHave have constrained it to “name” | “id” only.
1 2 |
const id0 = this.attributes.get("id") // ok const id = this.attributes.get("name") // ok |
In other words, if the T are used in parameters, then we can only pass in “id” or “name”, as T extends ModelMustHave means we are constrained to “name” and “id”.
Notice the components that conform to interface with generic T must work together like this. It returns T and takes in parameter T.
1 2 |
const result = this.attributes.getAll() this.sync.save(result) |
then in class ApiSync, we must clarify what T extends to again in order to extract the constrained properties as shown in function save.
We import ModelMustHave for T, so that in save, we can extract those properties from T.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import axios, { AxiosPromise } from 'axios' import { ModelMustHave } from './Model' export class ApiSync<T extends ModelMustHave> { constructor(public rootUrl: string) { console.log('ApiSync constructor') } save(data: T): AxiosPromise { const { id, name } = data // ok return id ? axios.put(`${this.rootUrl}/` + id, data) : axios.post(this.rootUrl, data) } } |
If we do not declare and extend ModelMustHave, the compiler will complain because our generic type T guarantees this type checking:
Creating a wrapper around the composition
Then by using these properties, we create a wrapper around their functionalities in order to do what we want to do.
For example, if we want to register and trigger events, we just use events.
But we if want to set data, we must use attributes to update locally, then use events to trigger
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
set(update: T): void { this.attributes... this.events... } fetch(): void { this.attributes... this.sync.... } save(): void { this.sync... this.trigger } |
As you can see, we have created an interface layer for it.
In addition, we know that this.events.on means we want to register events, but we don’t want to always type out this.event.on, so we rather just say this.on
Then, we can return references like so:
1 2 3 4 5 6 7 8 9 10 11 |
get on() { return this.events.on; } get trigger() { return this.events.trigger; } get get() { return this.attributes.get; } |
this is so that in our code, we can do this:
1 2 3 |
this.trigger("save") this.get("id") this.on("change", () => {}) |