Buy Me A Coffee

Table Of Contents

TypeScript makes developers' life easier.

In this article, we will walk through the creation of a set of types and interfaces.

The playground for this example is attached at the end of this article.

Conventions

In my practice, I prefer to use types to define unions. The role of interfaces is to describe an object structure.

Why?

Thus, it is easier to distinguish types and interfaces.

Here is an example:

/** a function that returns a generic type */
type GenericFunction<T> = () => T;
/**
 * a type that can be a generic value or
 * a function that returns a generic value
 */
type GenericMixedValue<T> = T | GenericFunction<T>;

/** description of an element */
interface IElement {
  /** disabled state is of type GenericMixedValue */
  disabled: GenericMixedValue<boolean>;
}

/** a function that randomly disables an element :) */
const disabled: GenericFunction<boolean> = (): boolean => {
  return Math.random() > 0.5 ? true : false;
};

/** declaration of an element */
const element: IElement = {
  disabled
};

In the above example, all types and interfaces are declared in CamelCase, but interfaces are prefixed with capital letter i.

This way, you will always know that the declaration refers to an interface:

const element: IElement;

On the other hand, you will perfectly see when the type is referred:

const disabled: GenericFunction<boolean> = (): boolean;

Button types

In our example, we will define a flexible button description with the help of TypeScript.

A button can be of any type from this set: positive, negative, neutral, and custom.

Here is the definition:

/** positive button type */
const buttonTypePositive = "positive";
/** negative button type */
const buttonTypeNegative = "negative";
/** neutral button type */
const buttonTypeNeutral = "neutral";
/** custom button type */
const buttonTypeCustom = "custom";

Next, let's define two types: ButtonTypeMain and ButtonType.

/** main button types */
type ButtonTypeMain = typeof buttonTypePositive | typeof buttonTypeNegative;

/** all button types */
type ButtonType =
  | typeof buttonTypeNeutral
  | typeof buttonTypeCustom
  | ButtonTypeMain;

The ButtonTypeMain will be used later to define a union type.

The ButtonTypeMain set includes a positive and negative type of button, while the ButtonTypeMain combines all of the types.

Discriminated unions

Discriminated unions are the wonder of typing!

With the help of such unions, you can gain flexibility via TypeScript!

Let's look further at our example.

Firstly, let's define a base interface IButton:

/** base button interface */
interface IButton {
  type: ButtonType;
  title?: string;
  disabled?: boolean;
  style?: string;
  action?: () => void;
}

Please note that all of the interface parameters are optional except for the type. Another important moment is that the type parameter can accept only values from ButtonType.

Next, we will use the type parameter to declare the interfaces of exact button types.

/** positive button interface */
interface IButtonPositive extends IButton {
  type: typeof buttonTypePositive;
  handleEnterKey?: boolean;
}

Ok, let's take a look at what has happened here a bit more thoroughly:

  • This interface extends and thus inherits properties of the IButton.
  • This interface sets the type property to buttonTypePositive.
  • This interface adds new optional field handleEnterKey.

So, we have created the first discriminated interface.

Next, let's add a discriminated interface for the negative button as follows:

/** negative button interface */
interface IButtonNegative extends IButton {
  type: typeof buttonTypeNegative;
  handleEscapeKey?: boolean;
}

Its declaration looks similar to the declaration of the IButtonPositive interface with two major differences:

  • This interface sets the type property to buttonTypeNegative.
  • This interface adds new optional field handleEscapeKey.

To catch the difference by hands, let's create a union type that will make the discrimination work:

/** test union button type */
type ButtonUnion = IButtonPositive | IButtonNegative;

Now let's try to define a positive button. Take a look at the screenshot:

Alt Text

TypeScript allows us to define the handleEnterKey field.

But if we will change the value to "negative":

Alt Text

TypeScript will allow defining the handleEscapeKey field!

Moreover, if we will try to set a not appropriate field we will get an error:

Alt Text

Conclusion: the discriminative types allow having a single flexible interface with the exact set of properties selected by the value of a field.

Intermediate interfaces

Interfaces can be extended. But what if at some level you have to derive several interfaces that have a lot in common?

In this case, we can leverage an intermediate interface.

Let's take a look at the example below:

interface IElement<T> {
  type: string;
  value: T;
}

interface IInput<T> extends IElement<T> {
  placeholder?: string;
  tooltip?: string;
}

interface IStringInput extends IInput<string> {
  type: "string";
}

interface INumberInput extends IInput<number> {
  type: "number";
  min?: number;
  max?: number;
  step?: number;
}

In the above example, IElement is a base interface that declares a generic value and property type to build discriminant interfaces.

Next, we declare the IInput, which acts as an intermediate interface. This interface adds a set of optional fields: placeholder and tooltip.

Lastly, we use the IInput to build two data type-specific interfaces: INumberInput and IStringInput.

Now, let's use that knowledge to finish building interfaces for the button.

Firstly, the intermediate interface:

/** extender interface for the neutral and custom buttons */
interface IButtonCustomized extends IButton {
  title: string;
  action: () => void;
  handleKey?: string;
}

The IButtonCustomized extends IButton interface and adds a common optional property: handleKey.

The important moment here is that this interface makes fields title and action mandatory.

Next, let's define the remaining interfaces:

/** neutral button interface */
interface IButtonNeutral extends IButtonCustomized {
  type: typeof buttonTypeNeutral;
}

/** custom button interface */
interface IButtonCustom extends IButtonCustomized {
  type: typeof buttonTypeCustom;
  style: ButtonStyle;
}

Working types

To start using everything that we build let's define a union type:

/** union button type */
type ButtonUnion =
  | ButtonTypeMain
  | IButtonPositive
  | IButtonNegative
  | IButtonNeutral
  | IButtonCustom;

And then export the type that will be used by end-users:

/** button type */
export type Button = GenericValueOrArray<ButtonUnion>;

Another side note: don't export intermediate and helping types/interfaces because they will confuse the users.

Example

Take a look at the example below:

import { Button } from "./types";

// defining the buttons
const buttons: Button = [
  "positive",
  { type: "negative" },
  {
    type: "neutral",
    title: "I won't do anything!",
    action: () => console.log("neutral hit")
  },
  {
    type: "custom",
    title: "Hit me!",
    action: () => console.log("custom hit"),
    style: "custom"
  }
];

Feel free to use the embedded playground at the bottom of the article to see the results.

From the above declaration, you can figure out several things:

  • Buttons list can be declared as a string, an object, or an array of strings/objects.
  • Predefined button types positive and negative can be declared as a string (later on, the parser will add default values if something is missing).
  • The value of the type property modifies the list of available fields.
  • TypeScript will throw an error if the user will try to define a field that is not defined in the respective interface.

Isn't that lovely?

Here goes a simplified example of the parser:

import { Button } from "./types";

/**
 * create the button
 * @param type button type
 * @param className button style
 * @param caption button title
 * @param action button action
 */
function create(
  type: string,
  className?: string,
  caption?: string,
  action?: ButtonAction
): HTMLButtonElement {
  const button = document.createElement("button");

  // implement your logic here

  return button;
}

/**
 * parse buttons list and create elements
 * @param buttons buttons list
 */
export function parse(buttons: Button): HTMLButtonElement[] {
  const result: HTMLButtonElement[] = [];
  if (typeof buttons === "string") {
    result.push(create(buttons));
  } else if (buttons instanceof Array) {
    buttons.forEach(button => {
      if (typeof button === "string") {
        result.push(create(button));
      }
      if (typeof button === "object" && button.type) {
        result.push(
          create(button.type, button.style, button.title, button.action)
        );
      }
    });
  } else if (typeof buttons === "object" && buttons.type) {
    result.push(
      create(buttons.type, buttons.style, buttons.title, buttons.action)
    );
  }
  return result;
}

Please, refer to the embedded code sandbox at the bottom of the post for a more solid example.

The parse function checks the type of the passed data:

  • If this is a string, then we try to create a button with default settings.
  • Otherwise, we iterate the array of strings/objects.
  • Lastly, we received an object that has a type field.

All the rest data will be skipped to avoid potential errors.

If data fits any conditions listed above, then we try to create a button and push it into the resulting array.

Code sandbox

Conclusion

TypeScript extends your abilities to define data structure, as well as to manipulate them.

Effectively combining the union types with sets of interfaces, we can forge the discriminated types that will add one more level of flexibility to your projects.

Moreover, this will also improve the user experience of other fellow developers who will benefit via time-saving and productivity-boosting.

This post is also available on DEV.