Type system showdown - Purescript and Typescript

This is tangentially related to the What Purescript needs thread, but I felt needed its own topic.

On the FPIndia discussion forum, we have had a long (and ongoing!) discussion about Purescript versus other languages like Typescript.

One of the interesting bits of discussion was about a non-trivial comparison of the typesystems of Purescript and Typescript - one that goes beyond the obvious and large differences such as Typescript’s typesystem is unsound, doesn’t support typeclasses, and doesn’t enforce purity.

@utkarshkukreti and I will be collecting a range of examples of type system features and how Purescript and Typescript implement them.

The repo is here - https://github.com/fpindia/type-systems-showdown, though it’s little more than a quick braindump right now. I will be refining it further as time permits.

Hope that it is useful to people, and contributions are very welcome! I am especially looking forward to any examples of things possible in Purescript and not Typescript and vice versa.

7 Likes

If you end up having a section on examples/practical applications, react-basic-hooks might be worth including (I’m biased though, as the author :sweat_smile:). TS/JS use tools like eslint to warn about invalid React hook usage, and those tools use conventions (like naming hooks useXYZ) to find the code they need to inspect. PS is able to encode those rules right in the type system.

12 Likes

That’s very nice! Checking out react-basic-hooks has been on my todo list forever.

Can you give me an example of code that will compile with Typescript’s bindings (assuming no eslint passes) but won’t compile with your library? Those are exactly the kind of things I am looking for!

1 Like

Sure. Here’s some TS which compiles but doesn’t produce a valid React component. It fails if you click the button 5 6 times:

import React, { useState } from "react";

export default function App() {
  const [count1, setCount1] = useState(0);
  let count2 = NaN, setCount2: React.Dispatch<React.SetStateAction<number>> = _ => {};
  if (count1 > 5) {
    [count2, setCount2] = useState(0);
  }
  return (
    <div>
      <button onClick={() => setCount1((s) => s + 1)}>Count 1: {count1}</button>
      <button onClick={() => setCount2((s) => s + 1)}>Count 2: {count2}</button>
    </div>
  );
}

As you’d expect, eslint does catch this problem if it’s enabled:

Eslint error: React Hook “useState” is called conditionally. React Hooks must be called in the exact same order in every component render. (react-hooks/rules-of-hooks)

Eslint is not a safe type system though. It only scans for specific syntax patterns and it’s fairly easy to fool it.

With this modification eslint is happy as well, but it still fails at runtime:

import React, { useState } from "react";

const sneakyUseState = useState;
//    ^---- rebind `useState`

export default function App() {
  const [count1, setCount1] = useState(0);
  let count2 = NaN, setCount2: React.Dispatch<React.SetStateAction<number>> = _ => {};
  if (count1 > 5) {
    [count2, setCount2] = sneakyUseState(0);
    //                    ^---- eslint no longer sees this problem
  }
  return (
    <div>
      <button onClick={() => setCount1((s) => s + 1)}>Count 1: {count1}</button>
      <button onClick={() => setCount2((s) => s + 1)}>Count 2: {count2}</button>
    </div>
  );
}

Here is the same component in PureScript:

module Main where

import Prelude
import Data.Number (nan)
import React.Basic.DOM as R
import React.Basic.DOM.Events (capture_)
import React.Basic.Hooks (Component, component, useState, (/\))
import React.Basic.Hooks as React

app :: Component {}
app = component "App" \_ -> React.do
  count1 /\ setCount1 <- useState 0
  
  count2 /\ setCount2 <-
    if count1 > 5 then
      useState 0
    else
      pure $ (0/0) /\ mempty
  
  pure $ R.div_
      [ R.button
          { onClick: capture_ $ setCount1 (_ + 1)
          , children: [ R.text $ "Count 1: " <> show count1 ]
          }
      , R.button
          { onClick: capture_ $ setCount2 (_ + 1)
          , children: [ R.text $ "Count 2: " <> show count2 ]
          }
      ]

The else branch fails to compile because it contains different hooks compared to the if..then branch:

  Could not match type

    Unit

  with type

    UseState Int Unit


while trying to match type t2
  with type UseState Int (UseState Int Unit)
while solving type class constraint

  Type.Equality.TypeEquals (UseState Int Unit)
                           (UseState Int (UseState Int Unit))

while checking that expression pure
  has type t0 -> t1
in value declaration app

where t1 is an unknown type
      t0 is an unknown type
      t2 is an unknown type

The error makes more sense if we look at the type of useState 0:

Hook (UseState Int) (Int /\ ((Int -> Int) -> Effect Unit)
-- ^ but that noisy argument at the end is
--   not important, so we'll ignore it:
Hook (UseState Int) _

Hook is just an alias for the Render type, so let’s expand it:

Render hooks (UseState Int hooks) _
       ^                   ^
-- note this `hooks` variable

You can read this as "useState takes the current ‘chain of hooks’ and extends it with UseState Int". In the error message above, the compiler is trying to match UseState Int (UseState Int Unit) (the if..then branch) and UseState Int Unit (the else branch). The only way to achieve the same type in the else branch is to use setState x where x :: Int exactly once.

These same protections work for all operations because they all use the same type system. It doesn’t matter whether the hook function is renamed or moved or whether you’re using if/else, when, or traverse.

Here’s an editable version of the TS example: https://codesandbox.io/s/agitated-faraday-p5lb6?file=/src/App.tsx
And the PS example: https://try.purescript.org/?gist=330cc8fcd49ccc66f3265a9af3ba48bf

14 Likes

(Slightly off-topic): as a purescript react hooks user in our production app, I can attest that my team and me are also able to use React.Basic.Hooks quite easily with none of us understanding all the type wizardry involved with Render and Hook and indexed monads and all that. In this particular case, I find the cost of the additional type safety to be nearly negligible.

8 Likes