Skip to content

Instantly share code, notes, and snippets.

@arosien
Created March 28, 2025 23:13
Show Gist options
  • Save arosien/f5bfeb315e5447768bf75261aa79e531 to your computer and use it in GitHub Desktop.
Save arosien/f5bfeb315e5447768bf75261aa79e531 to your computer and use it in GitHub Desktop.
tagless final implementation from noel welsh's talk "tagless final for humans" https://noelwelsh.com/talks/tagless-final-for-humans
// https://noelwelsh.com/talks/tagless-final-for-humans
trait Algebra:
type Ui[_]
trait Controls extends Algebra:
def text(prompt: String): Ui[String]
def choice(prompt: String, options: (String, Boolean)*): Ui[Boolean]
// etc...
trait Layout extends Algebra:
def and[A, B](t: Ui[A], b: Ui[B]): Ui[(A, B)]
// Declare a type for programs
trait Program[-Alg <: Algebra, A]:
// NOTE: delays choice of algebra to after the program is defined
def apply(using alg: Alg): alg.Ui[A]
// Define constructors returning programs
object Controls:
def text(prompt: String): Program[Controls, String] =
new Program[Controls, String]:
def apply(using alg: Controls): alg.Ui[String] =
alg.text(prompt)
def choice(
prompt: String,
options: (String, Boolean)*
): Program[Controls, Boolean] =
new Program[Controls, Boolean]:
def apply(using alg: Controls): alg.Ui[Boolean] =
alg.choice(prompt, options*)
// Define combinators using extension methods
extension [Alg <: Algebra, A](p: Program[Alg, A])
def and[Alg2 <: Algebra, B](
second: Program[Alg2, B]
): Program[Alg & Alg2 & Layout, (A, B)] =
new Program[Alg & Alg2 & Layout, (A, B)]:
def apply(using alg: Alg & Alg2 & Layout): alg.Ui[(A, B)] =
alg.and(p.apply, second.apply)
// Reached our goal
val ui =
Controls
.text("What is your name?")
.and(
Controls.choice(
"Are you enjoying Scalar?",
"Yes" -> true,
"Heck yes!" -> true
)
)
//> using toolkit typelevel:0.1.29
// example interpreter of the program
import cats.data.Const
import cats.syntax.all.*
// convert the UI to a sequence of tokens for pretty printing
object Tokenize extends Controls, Layout:
// pretend you're an A, but really you're an (accumulating) list of tokens
type Ui[A] = Const[List[Token], A]
enum Token:
case Indent
case Undent
case Pretty(value: String)
extension (tokens: List[Token])
def pretty: String =
def go(tokens: List[Token], indent: Int): String =
tokens match
case Nil => ""
case Token.Indent :: next => go(next, indent + 1)
case Token.Undent :: next => go(next, indent - 1)
case Token.Pretty(value) :: next => (" " * indent) + value + "\n" + go(next, indent)
go(tokens, 0)
// conjunction is the semigroupal product
def and[A, B](a: Ui[A], b: Ui[B]): Ui[(A, B)] =
Const.of(List(Token.Pretty("and"), Token.Indent)) *>
(a, b).tupled <*
Const.of(List(Token.Undent))
def choice(prompt: String, options: (String, Boolean)*): Ui[Boolean] =
Const.of(List(Token.Pretty(s"choice: $prompt, ${options.mkString(", ")}")))
def text(prompt: String): Ui[String] =
Const.of(List(Token.Pretty(s"text: $prompt")))
@main
def main =
println(ui(using Tokenize).getConst.pretty)
/*
and
text: What is your name?
choice: Are you enjoying Scalar?, (Yes,true), (Heck yes!,true)
*/
@noelwelsh
Copy link

Here's another interpreter, which doesn't quite match the talk but shows output on the terminal.

type Program[A] = () => A

object Simple extends Controls[Program], Layout[Program] {
  def and[A, B](first: Program[A], second: Program[B]): Program[(A, B)] =
    (first, second).tupled

  def text(
      label: String,
      placeholder: String,
      validation: Validation[String] = succeed
  ): Program[String] =
    () => {
      def loop(): String = {
        println(s"$label (e.g. $placeholder):")
        val input = StdIn.readLine

        validation(input).fold(
          msg => {
            println(msg)
            loop()
          },
          value => value
        )
      }

      loop()
    }

  def choice[A](label: String, options: Seq[(String, A)]): Program[A] =
    () => {
      def loop(): A = {
        println(label)
        options.zipWithIndex.foreach { case ((desc, _), idx) =>
          println(s"$idx: $desc")
        }

        Try(StdIn.readInt).fold(
          _ => {
            println("Please enter a valid number.")
            loop()
          },
          idx => {
            if idx >= 0 && idx < options.size then options(idx)(1)
            else {
              println("Please enter a valid number.")
              loop()
            }
          }
        )
      }

      loop()
    }
}

@main def example(): Unit = {
  def scalar[Ui[_]](
      controls: Controls[Ui],
      layout: Layout[Ui]
  ): Ui[(String, Boolean)] =
    layout.and(
      controls.text("What is your name?", "John Doe"),
      controls.choice(
        "Are you enjoying Scalar?",
        Seq("Yes" -> true, "Heck yes!" -> true)
      )
    )

  val consoleUi = scalar(Simple, Simple)
  val (name, enjoyment) = consoleUi()
  println(s"Hello $name!")
  println(s"You are enjoying Scalar!")
}

@arosien
Copy link
Author

arosien commented Mar 31, 2025

@noelwelsh ah nice. do you prefer the type parameter in the algebras like your snippet? or is it more of a user experience enhancement where you use a type-member instead, which you sort of suggested in your talk?

@noelwelsh
Copy link

No, type members are better IMO. The code is just what I had at hand from the demo system I wrote for the talk, which uses the old tagless final (Ye Olde Tagless Final). Similar code in the more modern style can be found at https://github.com/noelwelsh/aui and https://github.com/creativescala/gooey/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment