Skip to content

Instantly share code, notes, and snippets.

@prmichaelsen
Created February 26, 2024 09:26
Show Gist options
  • Save prmichaelsen/92b4f5f0dc95d2f40ba448458878d5dc to your computer and use it in GitHub Desktop.
Save prmichaelsen/92b4f5f0dc95d2f40ba448458878d5dc to your computer and use it in GitHub Desktop.
Entity definition

This gist gives a brief overview of my Entity type designed for my personal project.

I will first define some helper files and then show them used together in the final typing.

This gist includes X files:

  • utility-types.ts: Implements some complex types necessary to achieve the final type-checking logic to properly enforce the invariable characteristics that define the Entity type. These utility types are beyond magic to me. The file consists of types pulled from various sources online in addition to one final type authored by me. Each type builds off of a composite of one or more previously defined types. That it all works as it should--as intricate as it is-- really astounds me. This is some beautiful typescript, if you ask me. However, it is no concern of the reader; it is merely superfluous implementation details included for the benefit of those cursed with tedious curiousity.
  • entity.ts: The bread and butter
  • entity-readme.md: Explanation of the motivation behind Entity's design

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:

  TModels extends Model[]

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 📸

import { ArrayToIntersection, KeysOfTypes } from "ui";
export type Entity<
TModels extends Array<unknown> = any[],
> = {
id?: string,
creatorId?: string,
createTimeMs?: number,
updateTimeMs?: number,
types: KeysOfTypes<TModels>,
models: ArrayToIntersection<TModels>,
}
// credits goes to https://stackoverflow.com/a/50375286
export type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
export type ArrayToIntersection<T extends Array<unknown>> = UnionToIntersection<T[number]>;
// Converts union to overloaded function
type UnionToOvlds<U> = UnionToIntersection<
U extends any ? (f: U) => void : never
>;
type PopUnion<U> = UnionToOvlds<U> extends (a: infer A) => void ? A : never;
type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true;
// Finally (me)
// https://catchts.com/union-array
export type UnionToArray<T, A extends unknown[] = []> = IsUnion<T> extends true
? UnionToArray<Exclude<T, PopUnion<T>>, [PopUnion<T>, ...A]>
: [T, ...A];
// @prmichaelsen
export type KeysOfTypes<T extends unknown[]> =
UnionToArray<keyof ArrayToIntersection<T>>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment