How can I have better types for this monadic structure in Typescript?

I have been struggling to make typescript happy about the types. I am pulling data from our backend API and I wanted to give context to the data. Basically it is a monad with 4 shapes:

  • Initial (nothing)
  • Loading (maybe with percent?)
  • Failed (maybe with error?)
  • Loaded(with the actual value).

I want it to have the usual .of(), .map(), .chain() functions that you would expect in a monadic data structure.

After trying many different ways (with average success). I came up with this implementation:

type Kind = 'initial' | 'loading' | 'loaded' | 'failed' type DefaultError = any export class Data<T, E = DefaultError> {   private constructor(     public readonly kind: Kind,     public readonly data: T | undefined,     private readonly error: E | undefined,     private readonly percent: number | undefined,   ) {}    static initial<T, E>() {     return new Data<T, E>('initial', undefined, undefined, undefined)   }    static loading<T, E>(percent?: number) {     return new Data<T, E>('loading', undefined, undefined, percent)   }    static failed<T, E>(error?: E) {     return new Data<T, E>('failed', undefined, error, undefined)   }    static loaded<T, E>(value: T) {     return new Data<T, E>('loaded', value, undefined, undefined)   }    static orInitial<T, E>(value: Data<T, E> | undefined) {     if (value === undefined) {       return Data.initial<T, E>()     }     return value   }    static getData<T, E>(wrapped: Data<T, E>) {     return wrapped.data   }    static getDataOrElse<T, E>(defaultValue: T) {     return function(wrapped: Data<T, E>) {       return wrapped.kind === 'loaded' ? wrapped.data! : defaultValue     }   }    join() {     return this.data!   }    map<R>(f: (wrapped: T) => R): Data<R, E> {     switch (this.kind) {       case 'loaded':         return Data.loaded(f(this.data!))       case 'loading':         return Data.loading()       case 'failed':         return Data.failed(this.error)       case 'initial':         return Data.initial()     }   }       flatMap<R, E>(f: (wrapped: T) => Data<R, E>): Data<R, E> {     switch (this.kind) {       case 'loaded':         return f(this.data!)       case 'loading':         return Data.loading<R,E>()       case 'failed':         return Data.failed<R,E>((this.error as unknown) as E)       case 'initial':         return Data.initial<R,E>()     }   }    match<O1, O2, O3, O4>({     initial,     loaded,     loading,     failed,   }: {     initial: () => O1     loaded: (value?: T) => O2     loading: (percent?: number) => O3     failed: (error?: E) => O4   }) {     switch (this.kind) {       case 'loaded':         return loaded(this.data!)       case 'loading':         return loading(this.percent)       case 'failed':         return failed(this.error)       case 'initial':         return initial()     }   }    static isInitial<T, E>(wrapped: Data<T, E>): wrapped is Data<T, E> & { kind: 'initial' } {     return wrapped.kind === 'initial'   }    static isLoading<T, E>(wrapped: Data<T, E>): wrapped is Data<T, E> & { kind: 'loading'; percent?: number } {     return wrapped.kind === 'loading'   }    static isFailed<T, E>(wrapped: Data<T, E>): wrapped is Data<T, E> & { kind: 'failed'; error?: E } {     return wrapped.kind === 'failed'   }    static isLoaded<T, E>(wrapped: Data<T, E>): wrapped is Data<T, E> & { kind: 'loaded'; data: T } {     return wrapped.kind === 'loaded'   }    isInitial(): this is { kind: 'initial' } {     return this.kind === 'initial'   }    isLoading(): this is { kind: 'loading'; percent?: number } {     return this.kind === 'loading'   }    isFailed(): this is { kind: 'failed'; error?: E } {     return this.kind === 'failed'   }    isLoaded(): this is { kind: 'loaded'; data: T } {     return this.kind === 'loaded'   }    getValueOrElse(defaultValue: T) {     return this.kind === 'loaded' ? this.data : defaultValue   }    getPercentOrElse(defaultPercent: number) {     return this.kind === 'loading' ? this.percent : defaultPercent   }    getErrorOrElse(defaultError: E) {     return this.kind === 'failed' ? this.error : defaultError   } } 

I am not too happy with it. Mainly because I have to do null checks on error, value, percent fields. I wanted to have some sort of a tagged union type but I couldn’t make it work. Also the isLoaded() type guard doesn’t seem to really work when doing something like this:

// I cast to make typescript forget about the fact that the data is loaded const data:Data<number> = Data.loaded(3) as any as Data<number>  data.data // <-- says number | undefined, but I would really prefer it not even allowing me to access the field  if(data.isLoaded()) {   data.data // <-- number, so that is correct because it know the data is loaded }  const dataArray: Data<number>[] = [Data.failed(), Data.loaded(2)]  dataArray.filter(Data.isLoaded) .map(Data.getData) .map(x => x) // <-- number or undefined, that shouldn't happen  dataArray.filter(Data.isLoaded) .map(x => x.data) .map(x => x) // <-- number but not undefined? 

How can I best modify it and have strong typing with it?

This is a crucial part in our project because we use it everywhere.

MSO (Monadic second-order logic) Logic On Words

Let L be a language over $ \Sigma = \{a,b,c\}$ that contains all words, where the length $ |w|_b$ (number of all b’s) has remainder 1 if divided by 3.

MSO logic over words are definded as follow: enter image description here

I want to express this language using MSO logic. But my problem is how can I count the b’s to calculate the reminder? So it would be easiert if I just look on $ |w|$ , cause I could use max.

Is it preferable to “compose” monadic functions or “chain” them?

To the best of my understanding Monads were created to allow for composing functions with those that had potential side-effects – loosely speaking.

For me composition implies code like so:

f(g(h(x))) 

In order to achieve this in a programming language one has to “line up the types” correctly, so that the output of h(x) is an input to g(...). Implying that such a function chaining would require all functions in the chain to work at the “monadic level of abstraction” for the types to line up correctly.

However, at my workplace (and some library code) I see a lot of code that looks more like “function chaining” like so:

h(x).flatMap(g).map(f) 

This is NOT function composition AFAIK and this probably makes code harder to read IMHO since there’s cognitive overload in understanding “type translation” with flatMap/map thrown into the mix. One has to mentally unravel the computations to see how they all “line up”.

What is the common convention in the FP-world? I had a few discussions with my peers and got extremely strong push back for the compositional style f(g(h(... – almost everyone preferred the “chaining style”. Is there a common “style guide” that’s advocated for something like this?

From my POV, I’ve been exposed to LISP/Scheme and f(g(h... isn’t really alien and is rather more clean and reads like a DSL. The chaining is rather hacky.

Question: Should functions work at the monadic level to allow for composition or is the suggestion to work at the level of the wrapped value?

Concrete example:

checkForBlanks(csvRows).flatMap(checkForUniqueIds).map(buildCache))  

vs

buildCache(checkForUniqueIds(checkForBlanks(csvRows))) 

Method signatures (Non-monadic):

def checkForBlanks(csvRecords: Vector[Record]): Either[InternalDomainError, Vector[Record]]  def checkForUniqueIds(csvRecords: Vector[Record]): Either[InternalDomainError, Vector[Record]]  def buildCache(csvRows: Vector[Record]): MyCache  

Method Signatures (Monadic):

def checkForBlanks(csvRecords: Vector[Record]): Either[InternalDomainError, Vector[Record]]  def checkForUniqueIds(data: Either[InternalDomainError, Vector[Record]]): Either[InternalDomainError, Vector[Record]]  def buildCache(data: Either[InternalDomainError, Vector[Record]]): MyCache  

Common points for pushback:

  • Composing forces reading right to left
  • Composing makes functions think of Monads and will clutter responsibility of handling wrappers
  • It’s easier if a function just works on the “actual value” vs. a monadic wrapper since it’s “easier to reason”
  • It’s way more flexible to “chain” than compose
  • If you really want to “compose” add additional methods that “call out” to pure methods and interally wrap monads – unnecessarily complicated so don’t do it: E.g.:
def uniq(data: Either[InternalDomainError, Vector[Record]]): Either[InternalDomainError, Vector[Record]] =            data.flatMap(checkForUniqueIds) 

From an FP-adherence and best practices POV what’s the recommendation on should one do this for readability/maintainability?