2018-10-16

Scala-like match expression for JavaScript and TypeScript

The Scala language has a nice construct for branching based on the type of an object and at the same time extracting information from the the object. In Scala it's called a match expression.  ML and Haskell both have a similar construct, case expressions. Haxe has a similar switch expression.

Here is a Scala example straight from the Scala documentation site

First, we make a hierarchy of classes:
abstract class Notification

case class Email(sender: String, title: String, body: String) extends Notification

case class SMS(caller: String, message: String) extends Notification

case class VoiceRecording(contactName: String, link: String) extends Notification


Now we can write a function like this using a match expression.

def showNotification(notification: Notification): String = {
  notification match {

    case Email(email, title, _) =>
      s"You got an email from $email with title: $title"

    case SMS(number, message) =>
      s"You got an SMS from $number! Message: $message"

    case VoiceRecording(name, link) =>
      s"you received a Voice Recording from $name! Click the link to hear it: $link"
  }
}

Can we do the same in TypeScript?

(The following code used some types and functions defined in my collections module, which is currently here. https://github.com/theodore-norvell/PLAAY/tree/master/typescriptSrc)
First we define the abstract base class:
abstract class Notification {
    
    // Default implementation
    public exEmail<A>( f : (sender : String, title : String, body : String) => Option<a> ) : Option<A> {
        return none() ; }

    // Default implementation
    public exSMS<A>( f : (caller : String, message : String) => Option<A> ) : Option<A> {
        return none() ; }

    // Default implementation
    public exVoiceRecording<A>( f : (contactName : String, link : String) => Option<A> ) : Option<A> {
        return none() ; }
    
}


The three methods are deconstuctors or extractors. The generic Option<A> type is defined in the collections module. It is an abstract class that has two concrete realizations. One is None<A>; objects of type None<A> have no fields and indicate an absence of a value of type a. The other is Some<A>; objects of type Some<A> have one field of type a; they indicate the presence of a value of type a. We use Option values to encode the success or failure of a pattern match.

The default definition of the deconstuctors in the abstract base class is that they always fail.

In the concrete subclasses, we override the deconstructors so they may succeed when applied to the appropriate type of recipient.

class Email extends Notification {
    private sender : String ;
    private title : String ;
    private body : String ;
    constructor( sender : String, title : String, body : String)  {
        super() ;
        this.sender = sender; this.title = title; this.body = body ; }

    // Override
    public exEmail<A>( f : (sender : String, title : String, body : String) => Option<A> ) : Option<A> {
        return f( this.sender, this.title, this.body ) ; }
}
class SMS extends Notification {
    private caller : String ;
    private message : String ;
    constructor( caller : String, message : String)  {
        super() ;
        this.caller = caller; this.message = message; }

    // Override
    public exSMS<A>( f : (caller : String, message : String) => Option<A> ) : Option<A> {
        return f( this.caller, this.message ) ; }
}
class VoiceRecording extends Notification {
    private contactName : String ;
    private link : String ;
    constructor( contactName : String, link : String)  {
        super() ;
        this.contactName = contactName; this.link = link; }

    // Override
    public exVoiceRecording<A>( f : (contactName : String, link : String) => Option<A> ) : Option<A> {
        return f( this.contactName, this.link) ; }
}

Note that notification.exEmail(f), for example, will be none() if notification is not an email; otherwise it will be the result of applying f to the sender, title, and body of the notification; the result may still be none(), but it might be some(x), for some x.
Next we define three convenience functions that take a function return a function.

function caseEMail<A>( f : (sender : String, title : String, body : String) => Option<A> ) : (n:Notification) => Option<A> {
    return  (n:Notification) => n.exEmail( f ) ; }

function caseSMS<A>( f : (caller : String, message : String) => Option<A> ) : (n:Notification) => Option<A> {
    return  (n:Notification) => n.exSMS( f ) ; }

function caseVoiceRecording<A>( f : (contactName : String, link : String) => Option<A> ) : (n:Notification) => Option<A> {
    return  (n:Notification) => n.exVoiceRecording( f ) ; }

Now caseEMail(f)(notification) is just the same as notification.exEmail(f).
That concludes the definition of the Notification, its subclasses, and associated functions.


Now we are ready to write some client code that uses pattern matching.
function showNotification( notification : Notification ) : String {
    return match(
            notification,

            caseEMail( (sender, title, _) => some(
                `You got an email from ${sender} with title ${title}`)  ),

            caseSMS( (caller, message) => some(
                `You got an SMS from ${caller}! Message: ${message}` )  ),

            caseVoiceRecording( (name, link) => some(
                `You received a Voice Recording from ${name}. Click the link to hear it: ${link}` )  )
    ) ;
}

This code uses the match function defined in the collections module. The match function takes as arguments a value of any type and then a sequence of functions that return Option objects. It applies each of these functions in turn until one succeeds. The result of the call to match is the value that was wrapped in the Some object. If all the functions return None objects, then an error is thrown.

The code of the match function and a similar function optMatch that does not unwrap the result is given below:
    export function match<A,B>( x : A, ...cases : Array<(x:A)=>Option<B>> ) : B {
        const opt = optMatch( x, ...cases ) ;
        if( opt.isEmpty() ) throw new Error( "No case succeeded in match." ) ;
        return opt.first() ; }

    export function optMatch<a,B>( x : A, ...cases : Array<(x:A)=>Option<B>> ) : Option<B> {
        for( let i = 0 ; i<cases.length ; ++i ) {
            const f = cases[i] ;
            const b = f(x) ;
            if( !b.isEmpty() ) return b;
        }
        return none<B>() ;
    }

We can supply a default action/value by supplying a function that will always succeed:
     match(
            notification,

            caseEMail( (sender, title, _) => some(
                `You got an email from ${sender} with title ${title}`)  ),

            (_) => some(
                `You received a notification` )
    ) ;
}

A function can fail even if its pattern match succeeds. To help with this the collections module exports a function guard:
    export function guard<B>( p : boolean, f : () => B ) : Option<B> {
        if( p ) return some( f() ) ;
        else return none() ;
    }


With this we can write matches like this
    match(
            notification,

            caseEMail( (sender, title, _) => guard( sender=="Alice", () =>
                `PRIORITY email from ${sender} with title ${title}`) ),

            caseEMail( (sender, title, _) => some(
                `You got an email from ${sender} with title ${title}`)  ),

            caseSMS( (caller, message) => some(
                `You got an SMS from ${caller}! Message: ${message}` )  ),

            caseVoiceRecording( (name, link) => some(
                `You received a Voice Recording from ${name}. Click the link to hear it: ${link}` )  )
    ) ;


No comments:

Post a Comment