Let's examine the type for Entity
.
export interface Model {};
export type Entity<
TModels extends Model[],
> = {
id?: string,
creatorId?: string,
createTimeMs?: number,
updateTimeMs?: number,
types: KeysOfTypes<TModels>,
models: ArrayToIntersection<TModels>,
}
We note it includes some basic metadata.
Next, we note the types
and models
properties.
The Entity
schema relies on a concept of
models.
A model represents a specific set of structured data that defines the
properties, attributes, and relationships of an entity.
It serves as a blueprint or template used to organize and manage
data elements associated with the entity. Models help define the behavior,
constraints, and functionality of the entity based on the predefined data
structures and rules specified within them.
Each model is associated with a named type.
The types
field defines a run-time list of
the types of models the component Entity contains.
Let's consider the relationship these fields have
with each other along with their inferred struture:
{
// ...
types: KeysOfTypes<TModels>,
models: ArrayToIntersection<TModels>,
}
Each field is a derivative of a passed generic
type called TModels
.
TModels
is defined as follows:
In other words, TModels
is an array of
some type which implements the Model
type.
With that in mind, we will substitute TModels
with its super type in the models
definition.
models: ArrayToIntersection<Models[]>
Note that the models
field is defined as an
object that implements the intersection of
each model type in Models[]
. The ArrayToIntersection
type utility will take in an array of types with
various properties and return a single type
with all those properties. This is a useful behavior
we will later take advantage of.
In order to understand what form this final models
object will take, let's generically define a convention
all model types must follow.
export type ModelType = "<model_type>"
export const ModelName: ModelType = "<model_type>";
export interface Props {
// <props defined here> //
}
export interface InstanceModel extends Model {
[ModelName]: {
type: ModelType;
props: Props;
};
}
Allow me to point out points of interest. First, we'll consider:
// from entity.ts
export interface Model {};
// from the example above
export interface InstanceModel extends Model {
[ModelName]: {
type: ModelType;
props: Props;
};
}
The first question you might ask yourself is why are we
defining the expected structure of a model by convention instead
of by interface?
You might have a clue if you have worked with Redux before.
Consider the following:
type ActionType = 'START' | 'STOP';
interface Action {
type: ActionType,
payload: any,
}
interface StartAction extends Action {
type: 'START',
payload: { body: string },
}
interface StopAction extends Action {
type: 'STOP',
payload: { id: number },
}
If given an Action
object, we can check the type
field and handle the case appropriately. However,
there's no type saftey. For instance:
function Bar(action: Action) {
if (action.type === 'START') {
// no type error below
const foo = action.payload.id;
}
}
Why wouldn't this give a type error? Even though we constrained
type to START
, payload is still typed as
{ body: string } | { id: number }
In order to use type union discrimination, we must inverse the
order in which we define our types:
type ActionType = 'START' | 'STOP';
type Action =
| {
type: 'START',
payload: { body: string },
}
| {
type: 'STOP',
payload: { id: number },
};
Now when we try to use type union discrimination:
function Bar(action: Action) {
if (action.type === 'START') {
// Type Error: Property `id` does not exist on `{ body: string }`,
const foo = action.payload.id;
// ok because we know action is
// a START action
const bat = action.payload.body;
}
// Type Error: Property `body` does not exist on `{ body: string } | { id: number }`,
// This occurs because we haven't checked
// the action type yet so there is no way
// to infer the type of payload.
const baz = action.payload.body;
}
Defining our types this way allows us to leverage typescript's
powerful type union discrimination, but it relies on developers
knowing the conventional structure of a Redux "action".
So rather than doing this:
// from entity.ts
export interface Model<TModelType, TProps> {
// string indexed because types cannot
// be used to index an object
[k: string]: {
type: TModelType;
props: TProps;
};
}
// and then...
export type Model_A = Model<ModelType_A, Props_A>;
export type Model_B = Model<ModelType_B, Props_B>;
We explicitly define our types and then combine them later:
import { Model } from './entity.ts';
export type ModelType = "<model_type_a>"
export const ModelName: ModelType = "<model_type>";
export interface Props {
// <props defined here> //
}
export interface ModelA extends Model {
["<model_type_a>"]: {
type: "<model_type_a";
props: ModelAProps;
};
}
export interface ModelB extends Model {
["<model_type_b>"]: {
type: "<model_type_b";
props: ModelBProps;
};
}
And finally:
type models = ArrayToIntersection<[ModelA, ModelB]>
Which yields type:
{
["<model_type_a>"]: {
type: "<model_type_a";
props: ModelAProps;
},
["<model_type_b>"]: {
type: "<model_type_b";
props: ModelBProps;
},
}
Now let's examine types
:
type types = KeysOfTypes<[Model_A, Model_B]>
KeyOfTypes
is defined as:
type KeysOfTypes<T extends unknown[]> =
UnionToArray<keyof ArrayToIntersection<T>>
Let's substitute KeyOfTypes
with its definition:
type types = UnionToArray<keyof ArrayToIntersection<[Model_A, Model_B]>>
Let's also evaluate ArrayToIntersection
:
type types = UnionToArray<keyof {
["<model_type_a>"]: {
type: "<model_type_a";
props: ModelAProps;
},
["<model_type_b>"]: {
type: "<model_type_b";
props: ModelBProps;
},
}>
keyof
will then evaluate to "model_type_a" | "model_type_b"
:
type types = UnionToArray<"model_type_a" | "model_type_b">
And now, without diving into the implementation details, let's
evaluate UnionToArray
:
type types = ["model_type_a", "model_type_b"];
Now, consider the entire definition again:
export interface Model {};
export type Entity<
TModels extends Model[],
> = {
types: KeysOfTypes<TModels>,
models: ArrayToIntersection<TModels>,
}
In our case, TModels
is ["model_type_a", "model_type_b"]
.
However, KeyOfTypes<TModels>
is also ["model_type_a", "model_type_b"]
. So why not just set types: TModels
?
This is where the real magic comes in.
Let's define an entity:
const entity: Entity<["<model_type_a>", "<model_type_b>"]> = {
type: [ /* ... */ ],
models: {
// ...
},
}
This will require that the types
array matches TModels
and that each model exists within models
and all
props for each model are present.
const entity: Entity<["<model_type_a>", "<model_type_b>"]> = {
type: ["<model_type_a>", "<model_type_b>"],
["<model_type_a>"]: {
type: "<model_type_a",
props: {
// Type Error: Missing required property 'prop_a_1'
},
},
["<model_type_b>"]: {
type: "<model_type_b",
props: {
prop_b_1: 'some prop',
// Type Error: Property 'prop_a_1_' does not exist on Model_B
prop_a_1: 'another prop',
},
},
}
These type checks work only when type
is KeysOfType<TModels>
and fail when type
is TModels
.
I don't remember how or why this works. I can only suspect it's
because KeysOfType<TModels
derives its result from ArrayToIntersection<TModels>
and the use of inferred types
produces different rule checks.
Finally, a practical example:
const entity: Entity<AclModel, DocumentModel> = {
types: ["core--acl", "core--document"],
"core--acl": {
type: "core--acl",
props: {
roles: [ /* ... */ ],
},
},
"core--document": {
type: "core--document",
props: {
title: "My document",
body: { /* ... */ },
},
}
}
This means any entity is just a collection of different models.
If an entity has a model, you know you can perform certain operations on it, for instance, view, edit, or query.
In order to maximize flexibility, one can add or delete models
from an entity at will, assuming they do not violate the
invariant between types
and models
.
It is also possible to version types, although that is a discussion for later.
In summation, we have a type safe method that allows us
to define an entity with a list of types
and an object model
which represents the intersection of those types.
Pretty neat 📸