question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Discriminated unions for Customization Args

See original GitHub issue

The customization args in our codebase are currently being migrated to use union types instead of the pre-existing convention of any. For example: https://github.com/oppia/oppia/blob/dec1135d9da71daec3f00f268657a6a10f239b6d/core/templates/services/state-interaction-stats.service.ts#L41-L44 https://github.com/oppia/oppia/blob/dec1135d9da71daec3f00f268657a6a10f239b6d/core/templates/services/state-interaction-stats.service.ts#L48-L64 While this works, it still allows objects to be constructed incorrectly. For example, the following code would compile fine:

var fractionAnswer: IFractionDict = { /* ... */ };

var vizInfo: IVisualizationInfo = {
   data: [fractionAnswer],
   id: 'TextInput',
}

Describe the solution you’d like I’d like to strengthen the types to use discriminated unions instead. Doing so would make the code above a compile-time error, which could save developers a lot of time and effort; especially with more complicated interfaces.

To illustrate, here’s the previous example with discriminated unions:

export interface IFractionInputVisualizationInfo {
  id: 'FractionInput'; // The discriminant
  data: IFractionDict[];
}
export interface ITextInputVisualizationInfo {
  id: 'TextInput'; // The discriminant
  data: string[];
}

type IVisualizationInfo = ( // The union
  ITextInputVisualizationInfo |
  IFractionInputVisualizationInfo);

// Now, this becomes a compiler error:
var fractionAnswer: IFractionDict;
var vizInfo: IVisualizationInfo = {
  data: [fractionAnswer],
  id: 'TextInput',
}

Note that discriminated unions of interfaces can not be extended. However, classes can also contain discriminants:

class TextInputAnswer {
  visualizationId: 'TextInput'; // The discriminant
  // ...
}
class FractionInputAnswer {
  visualizationId: 'FractionInput'; // The discriminant
  // ...
}

type Answer = TextInputAnswer | FractionInputAnswer;

But this comes with a caveat: the value of the discriminant field is undefined.

This is because discriminants act as types, not values. To have the discriminant behave as expected, we’d need to do an actual assignment:

class TextInputAnswer1 {
  visualizationId: 'TextInput';
}

class TextInputAnswer2 {
  visualizationId: 'TextInput' = 'TextInput';
}


new TextInputAnswer1().visualizationId; // undefined
new TextInputAnswer2().visualizationId; // 'TextInput'

It’s my personal opinion that this confusing duplication is outweighed by the compile-time safety this convention would bring us.

Places where this is applicable

  • Interaction Answers
  • Interaction Rules
  • Interaction Customization Args
  • core/templates/domain/statistics/LearnerAnswerDetailsObjectFactory.ts

Issue Analytics

  • State:open
  • Created 3 years ago
  • Comments:19 (19 by maintainers)

github_iconTop GitHub Comments

1reaction
brianrodricommented, Jun 19, 2020

@nishantwrp do you have plans to implement this for Answer objects? I’d like to start updating all answers to meet the following pattern:

// core/templates/domain/interactions/${InteractionId}AnswerObjectFactory.ts

export interface I${InteractionId}AnswerBackendDict {
  interactionId: '${InteractionId}';
  // fields required by interaction id...
}

export class ${InteractionId}Answer {
  public readonly interactionId: '${InteractionId}' = '${InteractionId}';

  constructor(/* ... */) {}
}

export class ${InteractionId}AnswerObjectFactory {
  createFromBackendDict(
      backendDict: I${InteractionId}AnswerBackendDict): ${InteractionId}Answer {
    return new ${InteractionId}Answer(/* ... */);
  }
}
// core/templates/domain/interactions/AnswerObjectFactory.ts

import { /* ... */ } from
  'core/templates/domain/interactions/${InteractionId}AnswerObjectFactory';
// ...

export type IAnswerBackendDict = (
  ${InteractionId}AnswerBackendDict /* | ... */);

export type Answer = (
  ${InteractionId}Answer /* | ... */);

export class AnswerObjectFactory {
  constructor(
      private ${interactionId}AnswerObjectFactory:
        ${InteractionId}AnswerObjectFactory
      /* , ... */) {}

  createFromBackendDict(backendDict: IAnswerBackendDict): Answer {
    if (backendDict.interactionId === '${InteractionId}') {
      return this.${interactionId}AnswerObjectFactory.createFromBackendDict(
        backendDict);
    // } else if (...) {
    //   ...
    } else {
      invalidBackendDict: never = backendDict;
      throw new Error(
        'input does not satisfy the requirements of any interaction id');
    }
  }
}

For specs, we can test the invalidBackendDict branch by passing in an invalid dict with a type of any. Note: using any is the only way to bypass the compiler errors that would otherwise protect us from passing in such a dict.

/cc @seanlip @vojtechjelinek

To be clear, I’m not asking you to do everything by yourself – I’m happy and willing to share the workload. However, I’d like you to make the call on how to split this work given that it’s so intimately related to your project.

Thanks!

0reactions
nishantwrpcommented, Jun 30, 2020

@brianrodri I’ve updated the places where this needs to be done in the issue description. Please add things there so we can work on them later. Thanks!

Read more comments on GitHub >

github_iconTop Results From Across the Web

Discriminated Unions and Exhaustiveness Checking in ...
To create a discriminated union, all types that form the union must have the same literal member (boolean, string, number) but a unique...
Read more >
Discriminated Unions | F# for fun and profit
Discriminated Unions · Key points about union types · Constructing a value of a union type · Empty cases · Single cases ·...
Read more >
TypeScript type discriminated unions in function arguments
I'd like to get something like this: async action (name: 'create', args: { table: string, object ...
Read more >
TypeScript discriminated union and intersection types
In TypeScript, union and intersection types are used to compose or model types from existing types. These new composed types behave differently, ...
Read more >
Handbook - Unions and Intersection Types - TypeScript
Unions with Common Fields ... If we have a value that is a union type, we can only access members that are common...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found