Formity Labs

Formity's Advanced Type Inference, Explained

A detailed breakdown of how Formity leverages TypeScript's generics and conditional types to deliver full type safety for dynamic multi-step forms.

Overview

My goal with Formity was to deliver an exceptional developer experience — one that feels seamless, powerful, and fully type-safe. Achieving this meant ensuring developers could rely on TypeScript's IntelliSense for accurate autocompletion and error detection while building their forms.

In particular, I wanted developers to be able to define a type that represents both the structure of the form and the types of all its values, while letting TypeScript infer everything automatically — enabling a complete end-to-end type-safe workflow, as shown below.

1

In this article, I'll walk you through how I achieved advanced type inference and the reasoning behind it. We'll go step by step through the key ideas that shaped the solution, so you can clearly understand how it works and how everything fits together.

Typing the structure of the form

To provide full TypeScript support, I needed a way for developers to define a type that precisely represents both the structure of a multi-step form and the types of its values.

That's where the concept of the Values type came in — a single definition that encapsulates the entire form structure along with its data. Here's an example:

type Values = [
  {
    type: "form";
    form: { working: boolean };
  },
  {
    type: "cond";
    cond: {
      then: [
        {
          type: "form";
          form: { company: string };
        },
        {
          type: "return";
          return: {
            working: true;
            company: string;
          };
        },
      ];
      else: [
        {
          type: "form";
          form: { searching: boolean };
        },
        {
          type: "return";
          return: {
            working: false;
            searching: boolean;
          };
        },
      ];
    };
  },
];

This structure had to conform to the ListValues type, which defines the shape of a multi-step form:

type ItemValues =
  | FlowValues
  | FormValues
  | YieldValues
  | ReturnValues
  | VariablesValues;

type FlowValues = ListValues | CondValues | LoopValues | SwitchValues;

type ListValues = ItemValues[];

type CondValues = {
  type: "cond";
  cond: {
    then: ListValues;
    else: ListValues;
  };
};

type LoopValues = {
  type: "loop";
  loop: {
    do: ListValues;
  };
};

type SwitchValues = {
  type: "switch";
  switch: {
    branches: ListValues[];
    default: ListValues;
  };
};

type FormValues = {
  type: "form";
  form: object;
};

type YieldValues = {
  type: "yield";
  yield: {
    next: unknown[];
    back: unknown[];
  };
};

type ReturnValues = {
  type: "return";
  return: unknown;
};

type VariablesValues = {
  type: "variables";
  variables: object;
};

To reduce boilerplate and make form definitions more readable, I introduced utility types that let developers define the same structure with much cleaner syntax:

type Values = [
  Form<{ working: boolean }>,
  Cond<{
    then: [
      Form<{ company: string }>,
      Return<{
        working: true;
        company: string;
      }>,
    ];
    else: [
      Form<{ searching: boolean }>,
      Return<{
        working: false;
        searching: boolean;
      }>,
    ];
  }>,
];

Here are the utility types that make this possible:

type Cond<T extends { then: ListValues; else: ListValues }> = {
  type: "cond";
  cond: {
    then: T["then"];
    else: T["else"];
  };
};

type Loop<T extends ListValues> = {
  type: "loop";
  loop: {
    do: T;
  };
};

type Switch<T extends { branches: ListValues[]; default: ListValues }> = {
  type: "switch";
  switch: {
    branches: T["branches"];
    default: T["default"];
  };
};

type Form<T extends object> = {
  type: "form";
  form: T;
};

type Yield<T extends { next: unknown[]; back: unknown[] }> = {
  type: "yield";
  yield: {
    next: T["next"];
    back: T["back"];
  };
};

type Return<T> = {
  type: "return";
  return: T;
};

type Variables<T extends object> = {
  type: "variables";
  variables: T;
};

Getting the return output of the form

I wanted to use type inference across different parts of the codebase, but in this article, we'll focus on how I achieved type inference in the return output of the form, as shown below:

type Values = [
  Form<{ working: boolean }>,
  Cond<{
    then: [
      Form<{ company: string }>,
      Return<{
        working: true;
        company: string;
      }>,
    ];
    else: [
      Form<{ searching: boolean }>,
      Return<{
        working: false;
        searching: boolean;
      }>,
    ];
  }>,
];

type Type = ReturnOutput<Values>;

// type Type = {
//     working: true;
//     company: string;
// } | {
//     working: false;
//     searching: boolean;
// };

To understand how I did it, we'll start with a basic example and make it more complex step by step so you can understand the reasoning behind it.

Example #1

In this first example, we'll use the following ListValues type:

type ListValues = ReturnValues[];

type ReturnValues = {
  type: "return";
  return: unknown;
};

...and we'll use the following utility type:

type Return<T> = {
  type: "return";
  return: T;
};

Our goal is to create a ReturnOutput<T> type that returns the union of all the return element types found in the list, as shown below:

type Values = [
  Return<{ a: number }>,
  Return<{ b: number }>,
  Return<{ c: number }>,
];

type Type = ReturnOutput<Values>;

// type Type = {
//     a: number;
// } | {
//     b: number;
// } | {
//     c: number
// }

To achieve this, we need a type that recursively iterates through the list and accumulates each return type into a union. This can be done elegantly with the following code:

type ReturnOutput<T extends ListValues> = ListData<T, never>;

type ListData<T extends ListValues, U> = T extends [infer V, ...infer W]
  ? V extends ReturnValues
    ? W extends ListValues
      ? ListData<W, U | V["return"]>
      : never
    : never
  : U;

The ListData<T, U> type can be thought of as a type-level function that takes two parameters — the remaining list to process (T) and the accumulated union so far (U) — and returns the union of all the types from the return elements in the list combined with the accumulated union.

When the list is empty, it simply returns the accumulated union. Otherwise, it extracts the first element of the list (V) and the rest of the list (W), then calls itself recursively with the rest of the list and the accumulated union extended with the type of the first return element.

Example #2

In this second example, we'll use the following ListValues type:

type ItemValues = FormValues | ReturnValues;

type ListValues = ItemValues[];

type FormValues = {
  type: "form";
  form: object;
};

type ReturnValues = {
  type: "return";
  return: unknown;
};

...and we'll use the following utility types:

type Form<T extends object> = {
  type: "form";
  form: T;
};

type Return<T> = {
  type: "return";
  return: T;
};

Our goal is to create a ReturnOutput<T> type that returns the union of all the return element types found in the list, as shown below:

type Values = [
  Form<{ a: number }>,
  Return<{ b: number }>,
  Form<{ c: number }>,
  Return<{ d: number }>,
];

type Type = ReturnOutput<Values>;

// type Type = {
//     b: number;
// } | {
//     d: number
// }

To achieve this, we'll follow an approach similar to the one explained before, with the difference that we'll need to check that the element is a return element before extending the union type:

type ReturnOutput<T extends ListValues> = ListData<T, never>;

type ItemData<T extends ItemValues, U> = T extends ReturnValues
  ? U | T["return"]
  : U;

type ListData<T extends ListValues, U> = T extends [infer V, ...infer W]
  ? V extends ItemValues
    ? W extends ListValues
      ? ListData<W, ItemData<V, U>>
      : never
    : never
  : U;

Example #3

In this third example, we'll use the following ListValues type:

type ItemValues = FlowValues | FormValues | ReturnValues;

type FlowValues = CondValues | SwitchValues;

type ListValues = ItemValues[];

type CondValues = {
  type: "cond";
  cond: {
    then: ListValues;
    else: ListValues;
  };
};

type SwitchValues = {
  type: "switch";
  switch: {
    branches: ListValues[];
    default: ListValues;
  };
};

type FormValues = {
  type: "form";
  form: object;
};

type ReturnValues = {
  type: "return";
  return: unknown;
};

...and we'll use the following utility types:

type Cond<T extends { then: ListValues; else: ListValues }> = {
  type: "cond";
  cond: {
    then: T["then"];
    else: T["else"];
  };
};

type Switch<T extends { branches: ListValues[]; default: ListValues }> = {
  type: "switch";
  switch: {
    branches: T["branches"];
    default: T["default"];
  };
};

type Form<T extends object> = {
  type: "form";
  form: T;
};

type Return<T> = {
  type: "return";
  return: T;
};

Our goal is to create a ReturnOutput<T> type that returns the union of all the return element types found in the list, as shown below:

type Values = [
  Form<{ a: number }>,
  Cond<{
    then: [Form<{ b: number }>, Return<{ c: number }>];
    else: [Form<{ d: number }>, Return<{ e: number }>];
  }>,
];

type Type = ReturnOutput<Values>;

// type Type = {
//     c: number;
// } | {
//     e: number;
// };

To achieve this, we'll extend the previous implementation so that whenever a condition or switch element is encountered, the accumulated union is extended with the union of all return types found within each branch:

type ReturnOutput<T extends ListValues> = ListData<T, never>;

type ItemData<T extends ItemValues, U> = T extends CondValues
  ? CondData<T, U>
  : T extends SwitchValues
    ? SwitchData<T, U>
    : T extends ReturnValues
      ? U | T["return"]
      : U;

type ListData<T extends ListValues, U> = T extends [infer V, ...infer W]
  ? V extends ItemValues
    ? W extends ListValues
      ? ListData<W, ItemData<V, U>>
      : never
    : never
  : U;

type CondData<T extends CondValues, U> = BranchesData<
  [T["cond"]["then"], T["cond"]["else"]],
  U
>;

type SwitchData<T extends SwitchValues, U> = BranchesData<
  [...T["switch"]["branches"], T["switch"]["default"]],
  U
>;

type BranchesData<T extends ListValues[], U> = T extends [infer V, ...infer W]
  ? V extends ListValues
    ? W extends ListValues[]
      ? BranchesData<W, ListData<V, U>>
      : never
    : never
  : U;

The BranchesData<T, U> type works the same way as ListData<T, U>. It recursively iterates through each branch and accumulates the return types into a single union.

Example #4

In this fourth example, we'll address a case that we haven't considered before. Up to this point, we treated the return output as the union of all return element types. However, if a return element is not reachable because a previous one is always encountered, it shouldn't be included — as shown below:

type Values = [
  Form<{ a: number }>,
  Cond<{
    then: [Form<{ b: number }>, Return<{ c: number }>];
    else: [Form<{ d: number }>, Return<{ e: number }>, Return<{ f: number }>];
  }>,
  Return<{ g: number }>,
];

type Type = ReturnOutput<Values>;

// type Type = {
//     c: number;
// } | {
//     e: number;
// };

To achieve this, we'll make the types return a boolean indicating whether a return element is always reached. Then, in ListData<T, U>, we'll stop processing once this boolean becomes true:

type ReturnOutput<T extends Values> =
  ListData<T, never> extends [infer U, unknown] ? U : never;

type ItemData<T extends ItemValues, U> = T extends CondValues
  ? CondData<T, U>
  : T extends SwitchValues
    ? SwitchData<T, U>
    : T extends ReturnValues
      ? [U | T["return"], true]
      : [U, false];

type ListData<T extends ListValues, U> = T extends [infer V, ...infer W]
  ? V extends ItemValues
    ? W extends ListValues
      ? ItemData<V, U> extends [infer X, infer Y]
        ? Y extends true
          ? [X, true]
          : ListData<W, X>
        : never
      : never
    : never
  : [U, false];

type CondData<T extends CondValues, U> = BranchesData<
  [T["cond"]["then"], T["cond"]["else"]],
  U
>;

type SwitchData<T extends SwitchValues, U> = BranchesData<
  [...T["switch"]["branches"], T["switch"]["default"]],
  U
>;

type BranchesData<T extends ListValues[], U, V = true> = T extends [
  infer W,
  ...infer X,
]
  ? W extends ListValues
    ? X extends ListValues[]
      ? ListData<W, U> extends [infer Y, infer Z]
        ? V extends false
          ? BranchesData<X, Y, false>
          : BranchesData<X, Y, Z>
        : never
      : never
    : never
  : [U, V];

The ItemData<T, U> type returns true if a return element is always reached within the given element. For a return element, it's always true, while for condition and switch elements, it's true only if all branches lead to a return element, otherwise it's false.

To check whether all branches lead to a return element, BranchesData<T, U, V> uses the V parameter. Its default value is true, but if it encounters a branch where a return element isn't always reached, it becomes false, indicating that not all branches lead to a return element.

Complete code

With the full reasoning now explained, you can dive into the complete implementation on GitHub. There, you'll find all the concepts covered above, along with the additional types used in Formity, making it easy to explore, analyze, and experiment with the code on your own.