I am playing with TypeScript type system and I seem to have hit an invisible wall.
For starters, I have the Func
helper type (since Function
is not a generic type in TypeScript):
type Func <A, B> = (_: A) => B;
Then I have a base abstract class. It seems it can't be an interface because then TypeScript won't allow me to specialize the methods' signatures, see the derived class below, which is dubious, but it works with abstract class and abstract override
:
abstract class Wrappable <A> {
abstract andThen <B>(func: Func<A, B>): Wrappable<B>;
abstract andThenWrap <B>(func: Func<A, Wrappable<B>>): Wrappable<B>;
}
With the above definitions I can implement something like Maybe
(also note the abstract override
used to specialize the andThen
and andThenWrap
methods of the base class):
abstract class Maybe <A> extends Wrappable <A> {
abstract override andThen <B>(func: Func<A, B>): Maybe<B>;
abstract override andThenWrap <B>(func: Func<A, Maybe<B>>): Maybe<B>;
static option <A>(value: A | null | undefined): Maybe<A> {
return (!value) ? Maybe.none<A>() : Maybe.some<A>(value);
}
static some <A>(value: A): Some<A> {
return new Some<A>(value);
}
static none <A>(): None<A> {
return new None<A>();
}
}
class Some <A> extends Maybe <A> {
private value: A;
constructor(value: A) {
super();
this.value = value;
}
override andThen <B>(func: Func<A, B>): Maybe<B> {
return new Some(func(this.value));
}
override andThenWrap <B>(func: Func<A, Maybe<B>>): Maybe<B> {
return func(this.value);
}
}
class None <A> extends Maybe <A> {
constructor() {
super();
}
override andThen <B>(_: Func<A, B>): Maybe<B> {
return new None<B>();
}
override andThenWrap <B>(_: Func<A, Maybe<B>>): Maybe<B> {
return new None<B>();
}
}
The issues begin when I try to implement something more tricky, like this ExceptionW
, which is supposed to wrap another wrapper class:
class ExceptionW <R, W extends Wrappable<R>> extends Wrappable <W> {
private value: W;
constructor(value: W) {
super();
this.value = value;
}
override andThen <T, U extends Wrappable<T>>(func: Func<W, U>): ExceptionW<T, U> {
return new ExceptionW<T, U>(func(this.value));
}
override andThenWrap <T, U extends Wrappable<T>>(func: Func<W, ExceptionW<T, U>>): ExceptionW<T, U> {
return func(this.value);
}
}
This gives me a super blurry errors:
Property 'andThen' in type 'ExceptionW<R, W>' is not assignable to the same property in base type 'Wrappable<W>'.
Types of parameters 'func' and 'func' are incompatible.
Type 'B' is not assignable to type 'Wrappable<unknown>'.
and
Property 'andThenWrap' in type 'ExceptionW<R, W>' is not assignable to the same property in base type 'Wrappable<W>'.
Types of parameters 'func' and 'func' are incompatible.
Property 'value' is missing in type 'Wrappable<B>' but required in type 'ExceptionW<unknown, Wrappable<unknown>>'.
For what I can tell, it complains about the type parameter W
which I try to constraint to be Wrappable<?>
.
The second error misleads me to think it has something to do with the value
property. So I tried to mitigate it with yet another layer of abstraction:
abstract class WrapperTransformer <A, W extends Wrappable<A>> extends Wrappable <W> {
abstract override andThen <B, U extends Wrappable<B>>(func: Func<W, U>): WrapperTransformer<B, U>;
abstract override andThenWrap <B, U extends Wrappable<B>>(func: Func<W, WrapperTransformer<B, U>>): WrapperTransformer<B, U>;
}
And again I see similar errors:
Property 'andThen' in type 'WrapperTransformer<A, W>' is not assignable to the same property in base type 'Wrappable<W>'.
Types of parameters 'func' and 'func' are incompatible.
Type 'B' is not assignable to type 'Wrappable<unknown>'.
and
Property 'andThenWrap' in type 'WrapperTransformer<A, W>' is not assignable to the same property in base type 'Wrappable<W>'.
Types of parameters 'func' and 'func' are incompatible.
Call signature return types 'Wrappable<B>' and 'WrapperTransformer<unknown, Wrappable<unknown>>' are incompatible.
The types of 'andThen' are incompatible between these types.
Type '<B>(func: Func<B, B>) => Wrappable<B>' is not assignable to type '<B, U extends Wrappable<B>>(func: Func<Wrappable<unknown>, U>) => WrapperTransformer<B, U>'.
Types of parameters 'func' and 'func' are incompatible.
Types of parameters '_' and '_' are incompatible.
Type 'B' is not assignable to type 'Wrappable<unknown>'.
At this stage I am a bit lost at why this is happening and I tend to think this might be either an issue with the type system, quirks of TypeScript being a transpiler to JavaScript or just me misunderstanding something about the TypeScript type system.
Can somebody please point me in the right direction?
That's what I love about functional programming: you spend hours (or, as in my case, days) thinking about the problem and banging your head against the wall, but then you solve it with few neat lines of code.
TypeScript can not deduct the template type' constraints in this configuration.
But the bigger problem with the above snippet is actually not just the TypeScript itself, but the idea behind the code.
Just for the context: I was trying to implement few monads (namely: Maybe
and Either
) and a monad transformer (in the question - ExceptionT
).
With the code above, the implementation that just compiles could look like this:
class ExceptionW <A> implements Wrappable <A> {
constructor(private readonly value: Wrappable<A>) {}
andThen<B>(func: Func<A, B>): ExceptionW<B> {
return new ExceptionW<B>(this.value.andThen(func));
}
andThenWrap<B>(func: Func<A, ExceptionW<B>>): ExceptionW<B> {
return new ExceptionW<B>(this.value.andThenWrap(func));
}
}
So instead of constraining the template parameter of ExceptionW
, I just wrap the entire class around it. Roughly put, utilizing composition over inheritance.
Done, case dismissed.
However, going deeper the rabbit hole, the idea behind ExceptionT
is that we can use it as both the monad itself (ExceptionT
) and use its map
and flatMap
(in the code above - andThen
and andThenWrap
, correspondingly) to operate on the value it wraps.
Something like this would do:
class ExceptionW <A> implements Wrappable <A> {
constructor(private readonly value: Func0<Wrappable<A>>) {}
andThen<B>(func: Func<A, B>): ExceptionW<B> {
return new ExceptionW<B>(() => this.value().andThen(func));
}
andThenWrap<B>(func: Func<A, ExceptionW<B>>): ExceptionW<B> {
return new ExceptionW<B>(() => this.value().andThenWrap(func));
}
unsafeRun(): Wrappable<A> {
return this.value();
}
}
This implementation is a monad itself and it wraps the type A
.
The implementation in the question, where the ExceptionW
was wrapping the Wrappable<A>
won't do. The reason for this is that the monad interface (Wrappable
in this case) enforces very specific rules for andThen
and andThenWrap
- they must operate on the type the monad wraps.
So if the class signature looks like
class ExceptionW <A, W extends Wrappable<A>> implements Wrappable <W> {}
which is pretty much same as
class ExceptionW <A> implements Wrappable <Wrappable<A>> {}
then the methods inherited from the Wrappable
interface should look like this:
andThen(func: Func<Wrappable<A>, Wrappable<B>>): ExceptionW<Wrappable<B>> {}
andThenWrap(func: Func<Wrappable<A>, ExceptionW<Wrappable<B>>>): ExceptionW<Wrappable<B>> {}
And the value of this monad would be... questionable.
Thinking of how the monad would be used leads to the more reasonable implementation.
Say, there is a function which takes an XML as a string, parses it and returns the parsed XMLDocument
object:
const getResponseXML = (response: string): XMLDocument =>
new DOMParser().parseFromString(response, "text/xml");
This logic can fail though, if the string passed is not a valid XML, hence we would normally wrap it in try..catch
:
const getResponseXML = (response: string): XMLDocument => {
try {
const doc = new DOMParser().parseFromString(response, "text/xml");
// see https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString#error_handling
if (doc.querySelector('parsererror'))
throw new Error('Parser error');
return doc;
} catch (e) {
// ???
}
};
In a functional way, one can use the Maybe<XMLDocument>
monad for the simplest error handling or Either<Error, XMLDocument>
for a more fine-grain control (and feedback) of the error case.
Then the entire program would be built around one of those two monads:
const getResponseXML = (response: string): Either<Error, XMLDocument> => {
const doc = new DOMParser().parseFromString(response, "text/xml");
if (doc.querySelector('parsererror'))
return Either<Error, XMLDocument>.left(new Error('Parser error'));
return Either<Error, XMLDocument>.right(doc);
};
const program = getResponseXML('')
.andThen(doc => processDocument(doc));
But that getResponseXML
function is still an expression, not a value, meaning when you run the function, it will actually do some work instead of describe the intention to do the work (and the program won't be a chain of all the computations done to the work after it has been done sometime in the future).
This is uh-uh-no-good in functional programming world.
Sounds like a perfect use case for something with the name ExceptionW
.
class ExceptionW <A> implements Wrappable <A> {
constructor(private readonly task: Func0<Wrappable<A>>, private readonly exceptionHandler: Func<unknown, Wrappable<A>>) {}
andThen<B>(func: Func<A, B>): ExceptionW<B> {
return new ExceptionW<B>(
() => this.task().andThen(func),
(e) => this.exceptionHandler(e).andThen(func)
);
}
andThenWrap<B>(func: Func<A, ExceptionW<B>>): ExceptionW<B> {
return new ExceptionW<B>(
() => this.task().andThenWrap(func),
(e) => this.exceptionHandler(e).andThenWrap(func)
);
}
runExceptionW(): Wrappable<A> {
try {
return this.task();
} catch (e) {
return this.exceptionHandler(e);
}
}
}
With that, the code becomes much more functional-programming-friendly:
const getResponseXML = (response: string): ExceptionW<XMLDocument> =>
new ExceptionW(
() => {
const doc = new DOMParser().parseFromString(response, "text/xml");
if (doc.querySelector('parsererror'))
throw new Error('Parser error');
Either<Error, XMLDocument>.right(doc)
},
(e) => Either<Error, XMLDocument>.left(e)
);
So when you run the getResponseXML
function, nothing will happen - you will simply get the ExceptionW<XMLDocument>
object as a result. No parser will be created, no error returned. Then you can write your program in a manner of "what will happen when we actually run this code and get some result":
const program = (getResponseXML('invalid XML')
.andThen(doc => processXMLDocument(doc))
.runExceptionW() as Either<Error, XMLDocument>>)
.bimap(
(result) => console.log('success', result),
(error) => console.error('error', error)
);
Just to make this a bit more prettier:
class ExceptionW {
runExceptionW<W extends Wrappable<A>>(): W {
try {
return this.task() as W;
} catch (e) {
return this.exceptionHandler(e) as W;
}
}
}
const program = getResponseXML('invalid XML')
.andThen(doc => processXMLDocument(doc))
.runExceptionW<Either<Error, XMLDocument>>>()
.bimap(
(result) => console.log('success', result),
(error) => console.error('error', error)
);