Different Approaches To Modeling Data With TypeScript

Different Approaches To Modeling Data With TypeScript

TypeScript is a very well-planned type system that has type inference, structural subtyping, generic types, contextual typing, intersection types, literal types, union types, etc. In this blog post, I try to solve an example problem in two different ways. The first approach involves standard object-oriented programming (OOP) practices. The second involves powerful features of TypeScript, particularly union types, discriminated unions, and literal types.

The problem

To exemplify two different approaches to data modeling, I use a social media feed. Disclaimer: because this is an example (not production code), I may not have named functions, properties, etc., according to best practices. Now back to the social media example.

Social media feeds contain various types of updates, such as images, audio, plain text, and rich text. Each update type has some data that is common to all types (id, title) and a lot of information that is unique to that type.

Here are a few examples of updates in vanilla JavaScript:

var imagePost = {
  id: 1,
  title: 'Sam posted a photo',
  type: 0,
  mediaContentId: 123,
  description: 'Wedding party'
}

var mapPost = {
  id: 2,
  title: 'Rob added new location',
  type: 1,
  lat: '22.370400',
  long: '-5.132812',
  description: 'Wedding party location'
}

var image360Post = {
  id: 3,
  title: 'Dan posted a 360-image',
  type: 2,
  mediaContentId: 123,
  description: 'Panorama image from the wedding party',
  defaultDirection: 200
}

var soundclipPost = {
  id: 4,
  title: 'Rob posted a soundclip',
  type: 3,
  mediaContentId: 123,
  durationInSeconds: 20
}

The problem I try to solve is the data modeling part. Some properties are shared with update types, some properties are unique to the update type and few properties are shared with 1...n other update types.

I use TypeScript to be able to do safe refactorings (rename a property) and to get feedback from the compiler when I do breaking change.

Modeling data using TypeScript's type system, allows me to create reusable helpers more easily.

In the case of social feed, the problem is that the compiler doesn't know that I may have data that is shared between update types.

The objected-oriented programming (OOP) approach to the problem

One way to approach the modeling is to find common properties and move them to a base interface.

interface BaseUpdate {
  id: number,
  title: string,
  type: number
}

Then create each update type to inherit from the base interface.

interface ImageUpdate extends BaseUpdate {
  mediaContentId: 123,
  description: 'Wedding party'  
}

Creating a base interface is a step forward because it enables the creation of shared functionality. The shared functionality only has access to what is inside BaseUpdate (id, title, and type).

Creating a component that displays photos (which could be 360° or regular photos) doesn't help because the photos rely on data such as mediaContentId and description, which are not present in the base interface.

If I want to stay on the OOP path, I move these shared properties to another interface and inherit from it in the right places.

interface ImageMediaContent {
  mediaContentId: 123,
  description: 'Wedding party'  
}

interface ImageUpdate extends BaseUpdate, ImageMediaContent {
}

interface Image360Update extends BaseUpdate, ImageMediaContent {
  defaultDirection: number
}

This approach is doable, but I investigated other approaches.

Union types

What is a union type? It is a type that can be of several types, for example, function argument can be either number or string.

Most of the front-end developers have used APIs that can take several types. A textbook example of this is the jQuery API, which liberally incorporates this kind of API design.

For example, when I set a property in jQuery with .prop('myprop', false), I use one of the three available data types.

This is from jQuery's type definition:

prop(propertyName: string, value: string|number|boolean): JQuery;

With the vertical bar character |, I can specify that an argument has to be one of the defined update types:

function renderImageUpdate(update: ImageUpdate|Image360Update):React.ReactNode {
}

The function won't allow any other update types or falsy values. By defining the argument update this way, I have created a union type.

If I find myself repeating complicated union type definitions, then I create a custom type that I can use as a shortcut.

type ImageContentUpdate = ImageUpdate | Image360Update | HologramUpdate | VectorImageUpdate

But what does update contain if it is defined by a union type?

interface ImageUpdate {
  id: number;
  title: string;
  description: string;
  mediaContentId: number;
}

interface Image360Update {
  id: number;
  title: string;
  description: string;
  mediaContentId: number;
  defaultDirection: number;
}

type AnyTypeOfImageUpdate = ImageUpdate | Image360Update

function renderImageUpdate(update: AnyTypeOfImageUpdate) {
  ??
}
TypeScript union type

In this case, update would include all the shared properties of ImageUpdate and Image360Update. Adding new incompatible types to our custom type AnyTypeOfImageUpdate decreases the amount of properties union result and therefore gives a compile-time error.

Literal member and discriminated union

In the plain JavaScript example, I used a number to identify the type.

var imagePost = {
  id: 1,
  title: 'Sam posted a photo',
  type: 0,
  mediaContentId: 123,
  description: 'Wedding party'
}

The number was chosen to represent an enum value from the backend (as with C#/.NET), but with TypeScript I can use an enumeration or a constant to define the type.

enum UpdateType {
  Image,
  Image360,
  Map
}

interface ImageUpdate {
  id: number
  title: string
  type: UpdateType
  mediaContentId: string
  description: string
}

function isImageUpdate(update: AnyTypeOfUpdate) {
  if(update.type == UpdateType.Image) {
    /* I have only access to type */
  }
}

Unfortunately, I can't access the properties of ImageUpdate without casting inside the if-statement. This is because the type is defined at run time and not at compile time, so TypeScript cannot guarantee the type of the update.

A cleaner solution is to use a literal type: a small change on the line that defines a property named type.

interface ImageUpdate {
  id: number
  title: string
  type: UpdateType.Image
  mediaContentId: string
  description: string
}

The change looks subtle, but there are several benefits. First, no more casting; second, ImageUpdate always has the right type. When I create a new object, I must explicitly define type and then the properties that match that particular interface.

If I change the previously created function to this:

function isImageUpdate(update: AnyTypeOfUpdate) {
  if(update.type == UpdateType.Image) {
    // update is ensured to be type of ImageUpdate
  }
}

Casting inside the if-statement is no longer required.

Conclusion

In this blog post, I gave examples of various TypeScript features that may not be familiar to you if you're coming from a JavaScript, C#, or Java background. It is wise to have a fully loaded toolbox when facing a problem; inheritance isn't always the most elegant solution.

Note that the example functions I used took an update and did something with it. Note also that the least amount of information possible should be given to the functions you design. Say I have a component that displays images and supports giving the source and text (the alt-attribute of the image tag). There is no point in passing it the type image update; source and fallbackText are enough.

Discuss on Hacker News