alternative to switch statement for typescript discriminated union

3.2k views Asked by At

I have created this playground and here is the code:

type BundlerError = Error;
type BundlerWarning = Error;

export type BundlerState =
  | { type: 'UNBUNDLED' }
  | { type: 'BUILDING'; warnings: BundlerWarning[] }
  | { type: 'GREEN'; path: string;  warnings: BundlerWarning[] }
  | { type: 'ERRORED'; error: BundlerError }

const logEvent = (event: BundlerState) => {
    switch (event.type) {
      case 'UNBUNDLED': {
        console.log('received bundler start');
        break;
      }
      case 'BUILDING':
        console.log('build started');
        break;
      case 'GREEN':
        if(event.warnings.length > 0) {
          console.log('received the following bundler warning');

          for (let warning of event.warnings) {
              warning
            console.log(warning.message);
          }
        }
        console.log("build successful!");
        console.log('manifest ready');
        break;
      case 'ERRORED':
        console.log("received build error:");
        console.log(event.error.message);
        break;
    }
}

BundlerState is a discriminated union and the switch narrows the type.

The problem is that it does not scale and big expanding switch statements are pretty horrible.

Is there a better way I can write this and still keep the nice type narrowing?

You cannot do this:

const eventHandlers = {
  BUNDLED: (event: BundlerState) => event.type // type is not narrowed
  // etc,
};

const logEvent = (event: BundlerState) => eventHandlers['BUNDLED'](event);

Because the type is not narrowed.

6

There are 6 answers

0
Souperman On BEST ANSWER

I noticed the fp-ts tag so I figure I'll give the approach with that library in mind. fp-ts defines a lot of fold operations that achieve essentially the result you're looking for for their various algebraic types. The general idea is to define a function that does the narrowing for you, then you define handlers for each of the cases.

Simple Example

import { Option, some, none, fold } from 'fp-ts/lib/Option';
const x: Option<number> = some(1);
const y: Option<number> = none;

const printSomeNumber = fold(
  () => console.log('No number'),
  (n) => console.log(n);
);

printSomeNumber(x); // Logs 1
printSomeNumber(y); // Logs "No number" 

So for your type, you could write something like this:

import { absurd } from 'fp-ts';
type BundlerError = Error;
type BundlerWarning = Error;
enum StateType {
  Unbundled = 'UNBUNDLED',
  Building = 'BUILDING',
  Green = 'GREEN',
  Errored = 'ERRORED',
}
type Unbundled = { type: StateType.Unbundled; };
type Building = { type: StateType.Building; warnings: BundlerWarning[]; };
type Green = { type: StateType.Green; path: string; warnings: BundlerWarning[]; };
type Errored = { type: StateType.Errored; error: BundlerError };
export type BundlerState = Unbundled | Building | Green | Errored;

const fold = <ReturnType extends any>(
  a: (state: Unbundled) => ReturnType,
  b: (state: Building) => ReturnType,
  c: (state: Green) => ReturnType,
  d: (state: Errored) => ReturnType,
) => (state: BundlerState): ReturnType => {
  switch(state.type) {
    case StateType.Unbundled:
        return a(state);
    case StateType.Building:
        return b(state);
    case StateType.Green:
        return c(state);
    case StateType.Errored:
        return d(state);
    default:
        // This is a helper from fp-ts for throwing when the value should be never.
        return absurd(state);
  }
};

const logType = fold(
    (state) => console.log(state.type),
    (state) => console.log(state.type),
    (state) => console.log(state.type),
    (state) => console.log(state.type),
);

Playground so you can inspect each of the states.

So fold is a higher order function for creating a handler for your type (in the same way as it is for Option).

2
Alexander Popov On

Maybe you can use a map of handlers where key is event type (UNBUNDLED, BUILDING, etc) and value is a handler that needs to be called:

type BundlerError = Error;
type BundlerWarning = Error;

export type BundlerState =
  | { type: 'UNBUNDLED' }
  | { type: 'BUILDING'; warnings: BundlerWarning[] }
  | { type: 'GREEN'; path: string;  warnings: BundlerWarning[] }
  | { type: 'ERRORED'; error: BundlerError }

const eventHandlers = {
  UNBUNDLED: (event: BundlerState) => console.log('received bundler start'),
  BUILDING: (event: BundlerState) => console.log('build started'),
  GREEN: (event: BundlerState) => console.log('received the following bundler warning'),
  ERRORED: (event: BundlerState) => console.log("received build error:"),
};

const logEvent = (event: BundlerState) => eventHandlers[event.type](event);

Here is link to playground.

0
Tarun Lalwani On

You can do it two ways

const eventHandlers = {
  BUNDLED: (event: Extract<BundlerState, { type: 'BUILDING' }>) => event. 
  // etc,
};

or


type BundlerBuildingState = Extract<BundlerState, { type: 'BUILDING' }> // will be  { type: "link"; url: string; }

const eventHandlers = {
  BUNDLED: (event: BundlerBuildingState) => event. 
  // etc,
};

Intellisense working

1
artur grzesiak On

Here is a pattern (or its variations) I use quite often.

type BundlerStatesDef = {
   UNBUNDLED: {}
   BUILDING: { warnings: BundlerWarning[] }
   GREEN: { path: string; warnings: BundlerWarning[] }
   ERRORED: { error: BundlerError }
}
type BundlerStateT = keyof BundlerStatesDef
type BundlerStates = { [K in BundlerStateT]: { type: K } & BundlerStatesDef[K] }
type BundlerHandler<K extends BundlerStateT> = (params: BundlerStates[K]) => void
type BundlerHandlers = { [K in BundlerStateT]: BundlerHandler<K> }

With types defined as above you could have a pretty ergonomic implementation, like so:

const handlers: BundlerHandlers = {
  UNBUNDLED: params => console.log(params),
  BUILDING: params => console.log(params),
  GREEN: params => console.log(params),
  ERRORED: params => console.log(params)
}

const logEvent = <E extends BundlerStateT>(event: BundlerStates[E]) =>
  (handlers[event.type] as BundlerHandler<E>)(event)

PLAYGROUND


Sticking closer you your original definition and be even less verbose you could do it like that:

type BundlerError = Error
type BundlerWarning = Error

export type BundlerState =
  | { type: 'UNBUNDLED' }
  | { type: 'BUILDING'; warnings: BundlerWarning[] }
  | { type: 'GREEN'; path: string;  warnings: BundlerWarning[] }
  | { type: 'ERRORED'; error: BundlerError }

export type BundlerHandlers = { [K in BundlerState['type']]: (params: Extract<BundlerState, { type: K }>) => void }

const handlers: BundlerHandlers = {
  UNBUNDLED: params => console.log(params),
  BUILDING: params => console.log(params),
  GREEN: params => console.log(params),
  ERRORED: params => console.log(params)
}

const logEvent = (event: BundlerState) =>
  (handlers[event.type] as (params: Extract<BundlerState, { type: typeof event['type'] }>) => void )(event)

PLAYGROUND

0
axiac On

The solution for your problem is to use OOP and polymorphism.

Let BundlerState be an abstract base class that declares the public interface:

export abstract class BundlerState {
  public abstract logEvent(): void;
}

Then extend it for each value of type:

export class UnbundledState extends BundlerState {
  public logEvent(): void {
    console.log('received bundler start');
  }
}

export class BuildingState extends BundlerState {
  public constructor(private warnings: BundlerWarning[]) {}

  public logEvent(): void {
    console.log('build started');
  }
}

export class GreenState extends BundlerState {
  public constructor(private path: string; private warnings: BundlerWarning[]) {}

  public logEvent(): void {
    if(event.warnings.length > 0) {
      console.log('received the following bundler warning');

      for (let warning of event.warnings) {
        console.log(warning.message);
      }
    }
    console.log("build successful!");
    console.log('manifest ready');
  }
}

export class ErroredState extends BundlerState {
  public constructor(private error: BundlerError) { }

  public logEvent(): void {
    console.log("received build error:");
    console.log(event.error.message);
  }
}

This way a new type can be added without modifying the existing code.

Usage

The user code changes a little bit. Instead of:

const state: BUndlerState = { type: 'BUILDING'; warnings: [ warning1, warning2 ] };
logState(state);

It becomes:

const state: BundlerState = new BuildingState([warning1, warning2]);
state.logState();

Further discussion

  • Did you notice what happened with the property type?
    It disappeared (because it is not needed any more); its value is now encoded in the type itself (into the class name).

  • OOP usually produces (apparently) more code than the procedural approach. The suggested solution has 42 lines (including the empty lines) while the original has only 33 lines.

    But each class can and should stay in its own file. This leads to smaller pieces of code that are easier to read and understand.

Also, new types of BundlerState (new classes) can be added (in new files) without changing the existing files.

  • A base class is not even needed; an interface can be used instead. The state classes do not have common properties (the field type disappeared because it is not needed). All they have in common is one behaviour (the logEvent() method) and this can be expressed by an interface:

    interface BundlerState {
       logEvent(): void
    }
    

    Then each state class will implement BundlerState instead of extending it. The user code does not change.

0
concision On

You will need to narrow down the BundlerState parameter in your event handler lambdas using Extract<BundlerState, {type: 'TYPE'}. You will want to ensure your parameter matches the key in the event handler map though (e.g. eventHandlers['TYPE'] is of type (event: Extract<BundlerState, { type: 'TYPE' }>) => any. This can be accomplished by creating a special EventHandlers type that enforces this relationship between the key and the event handler's lambda signature.

The syntactic ugliness can also be significantly reduced by defining a type to narrow BundlerState using the previously mentioned Extract<...> method.

// generic parameter is optional; if no generic is passed, returns the full BundleState union
type NarrowedBundlerState<T extends BundlerState["type"] = BundlerState["type"]> = Extract<BundlerState, { type: T }>;
// event handler map that ensures a relationship between the key and the event handler's lambda signature
type EventHandlers = { [T in BundlerState["type"]]: (event: NarrowedBundlerState<T>) => any; };

const eventHandlers: EventHandlers = {
    // allowed entries; we can also access the narrowed type's properties correctly
    UNBUNDLED: (event: NarrowedBundlerState<"UNBUNDLED">) => event.type,
    BUILDING: (event: NarrowedBundlerState<"BUILDING">) => event.warnings,
    GREEN: (event: NarrowedBundlerState<"GREEN">) => event.path,
    ERRORED: (event: NarrowedBundlerState<"ERRORED">) => event.type,
};
const badEventHandlers: Partial<EventHandlers> = {
    // a non-allowed entry because the key and 'type' parameter do not match
    ERRORED: (event: NarrowedBundlerState<"GREEN">) => event.type,
};

const logEvent = (event: BundlerState) => {
    // a caveat is you need to cast the retrieved event handler to a more general event handler lambda signature
    (eventHandlers[event.type] as (event: BundlerState) => any)(event);
    // alternatively you could cast to (params: NarrowedBundlerState<typeof event.type>) => any
    // however, it resolves to (event: BundlerState) => any anyways
};

If you do not want to define all of the possible event types in your event handler map, you may use a Partial<EventHandlers> type.