Toxic optionals – TypeScript

In my previous blog post I was talking about the inherent Toxic flexibility of the JavaScript language itself.

I made a case for cutting down the number of options a piece of code can have so our tool chain including your IDE of choice can help you se…


This content originally appeared on DEV Community and was authored by András Tóth

In my previous blog post I was talking about the inherent Toxic flexibility of the JavaScript language itself.

I made a case for cutting down the number of options a piece of code can have so our tool chain including your IDE of choice can help you serving with just the right thing you need at the right moment, or help you "remember" every place a given object was used without having to guess it by use a "Find in all files" type dialog.

However toxic flexibility can sprout up in TypeScript as well.

Let's start with a real life product example!

Building a survey

In our company we have to deal with surveys aka questionnaires. Overly simplified each survey will have a number of questions of different types.

Let's say our product manager says: "I want people to have the ability to add an integer or a string question."

For example:

  • How many batteries were present? => integer question
  • How would you describe your experience? => string question

Let's write the types down (I omit most of the details like IDs to keep it clean):

type Question = {
  answerType: 'string' | 'integer';
  label: string;
}

The next day the product manager comes in and says: "I want these types to have constraints: a string question might have minimum and maximum lengths, while integer questions might have minimum and maximum values."

OK, we scratch our heads and then decide to go "smart" and say: "You know what? I will have just a min and max property. The property min will mean if it is string a minimum length and if it is integer a minimum value."

type Question = {
  answerType: 'string' | 'integer';
  label: string;
  min: number;
  max: number;
}

(Note: at this point we started to stray away from true domain objects to make our initial implementation simpler. I will come back to this later.)

The next day the product manager comes in again: "All was well and good, but now I want a boolean question (a yes-no one), which does not have a min-max type of constraint. Also I want min-max values to be optional. Also people want to make photos and want to have a constraint over the maximum number of photos they can make but I do not wish to set a minimum."

So we go and update our type:

type Question = {
  answerType: 'string' | 'integer' | 'yes-no' | 'images';
  label: string;
  min?: number;
  max?: number;
  maxNumberOfPhotos?: number;
}

Finally the product manager comes to tell: "Oh no, I completely forgot! We want people to have a question type where they select from a list of options with a radio button. I will call it single choice."

Now things start to sour:

type Question = {
  answerType: 'string' | 'integer' | 'yes-no' 
            | 'image' | 'single-choice';
  label: string;
  min?: number;
  max?: number;
  maxNumberOfPhotos?: number;
  choices?: string[];
}

Looks like we can handle all these types with one excellent type! Or is there a drawback...? ?

Cartesian products and the poison of optional properties

Let's see what kind of objects we can make from this Question type:

// no surprises
const validImage: Question = {
  answerType: 'image',
  maxNumberOfPhotos: 3,
};

const validInteger: Question = {
  answerType: 'integer',
  min: 1,
  max: 10,
};

// but also this will compile...
const invalidYesNo: Question = {
  answerType: 'yes-no',
  maxNumberOfPhotos: 13,
  choices: ['lol', 'wat'],
}

Whenever you use optional you create the Cartesian product of all possible missing and added properties! We have 4 optional properties now we will have 24 options: 16 possible types of which only 4 of them are valid domain objects!

Look at how it all ends... up ⚠️

A several years in my coding career I got really aware that to write good code I should not just see my module (be it a class or a function or a component) on its own, I constantly need to check how it is used: is it easy or is it cumbersome to interact with the object I have just defined.

The type I created above will be extremely cumbersome to use:

// overly simplified logic just to show the problem
// This is a simple React example, don't worry if you 
// are not familiar with it
function ShowQuestion(question: Question) {
  if (question.type === 'yes-no' && (question.max || question.min || question.maxNumberOfPhotos || question.choices)) {
    throw new Error('Uh-oh, invalid yes-no question!');
  }

  if (question.type === 'single-choice' 
   && (question.max 
      || question.min 
      || question.maxNumberOfPhotos)
   && !question.choices) {
    throw new Error('Uh-oh, invalid single-choice question!');
  }

   // and so on and so on - finally we can show it

  return <div>
    {question.max && question.type === 'integer' && 
  <Constraint label="Maximum value" value={question.max} />}
    {question.maxNumberOfPhotos && question.type === 'image' &&
   <Constraint label="Maximum no of photos" 
 value={question.maxNumberOfPhotos} />}
    ...
  </div>;
}

Drill this into your forehead:

Every optional property you set up will warrant at least an if somewhere else.

Union type to the rescue!

I promised to come back to the domain objects. In everyone's mind we only have 5 types. Let's make then only five (plus a base)!

type QuestionBase = {
  answerType: 'string' | 'integer' | 'yes-no' 
            | 'image' | 'single-choice';
  label: string;
}

// I am not going to define all of them, they are simple
type IntegerQuestion = QuestionBase & {
  // pay attention to this: answerType is now narrowed down
  // to only 'integer'!
  answerType: 'integer';
  minValue?: number;
  maxValue?: number;
}

type ImageQuestion = QuestionBase & {
  answerType: 'image';
  // we can make now things mandatory as well!
  // so if product says we must not handle
  // infinite number of photos
  maxNumberOfPhotos: number;
}

// ...

type Question = IntegerQuestion | ImageQuestion; 
// | YesNoQuestion | ...

How do we use them? We are going to use narrowing (see link for more details).

A case for some switch-case

One of my favourite things to do when you have to deal with a stream of polymorphic objects is to use switch-case:

function renderAllQuestions(questions: Question[]) {
  questions.forEach(question => renderOneQuestion(question));
}

function renderOneQuestion(question: Question) {
  // question.type is valid on all question types
  // so this will work
  switch (question.type) {
    case 'integer':
      renderIntegerQuestion(question);
      return;
    case 'string':
      renderStringQuestion(question);
      return;
    //...
  }
}

// Check the type! We are now 100% sure
// it is the right one.
function renderIntegerQuestion(question: IntegerQuestion) {
  // your IDE will bring `maxValue` up after you typed 'ma'
  console.log(question.maxValue);

  return <div>
    {question.maxValue && 
      <Constraint label="Maximum value" value={question.maxValue} />
  </div>
}

// ...

Disclaimer: I know there are nicer React patterns than having a render function for everything. Here I just wanted to make a kind of framework-agnostic example.

What happened above is that we were able to funnel a set of types to concrete types without having to use the dangerous as operator or to feel out the type at hand with duck-typing.

Summary

To sum it all up:

  • optional properties result in conditions that check them leading to Cartesian product explosion
  • we cut down the number of invalid possibilities to only 5 valid domain objects
  • these domain objects also match the terminology product management and clients have
  • since we encapsulated what is common in QuestionBase now we are free to add question specific extras and quirks
  • instead of having a god-component question handler that handles rendering of a question with an insane set of conditions (and growing!) we now boxed away the differences neatly in separate, aptly-typed components
  • we can also handle an array of different values and without any type casting with (e.g. question as IntegerQuestion) we created a type-safe system

Questions? Did I make errors?
Let me know in the comments.


This content originally appeared on DEV Community and was authored by András Tóth


Print Share Comment Cite Upload Translate Updates
APA

András Tóth | Sciencx (2021-09-28T21:03:44+00:00) Toxic optionals – TypeScript. Retrieved from https://www.scien.cx/2021/09/28/toxic-optionals-typescript/

MLA
" » Toxic optionals – TypeScript." András Tóth | Sciencx - Tuesday September 28, 2021, https://www.scien.cx/2021/09/28/toxic-optionals-typescript/
HARVARD
András Tóth | Sciencx Tuesday September 28, 2021 » Toxic optionals – TypeScript., viewed ,<https://www.scien.cx/2021/09/28/toxic-optionals-typescript/>
VANCOUVER
András Tóth | Sciencx - » Toxic optionals – TypeScript. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2021/09/28/toxic-optionals-typescript/
CHICAGO
" » Toxic optionals – TypeScript." András Tóth | Sciencx - Accessed . https://www.scien.cx/2021/09/28/toxic-optionals-typescript/
IEEE
" » Toxic optionals – TypeScript." András Tóth | Sciencx [Online]. Available: https://www.scien.cx/2021/09/28/toxic-optionals-typescript/. [Accessed: ]
rf:citation
» Toxic optionals – TypeScript | András Tóth | Sciencx | https://www.scien.cx/2021/09/28/toxic-optionals-typescript/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.