Indexed Access Types

Proposal

I’d find it useful and somehow consequent if we could access types from record type fields.
In TypeScript they’re called Indexed Access Types

type Foo = {
  bar :: Int,
  baz :: String
}

type FooBar = Foo["bar"] -- Int
type FooBaz = Foo["baz"] -- String

This would be analogous to the dot-notation we have to access record fields on the term level: someRecord.foo

Motivation

Currently, if one needs to refer to both a record type as a whole and the types of some sub fields, the following pattern can be used:

type Foo = {
  bar :: FooBar,
  baz :: FooBaz
}

type FooBar = Int
type FooBaz = String

This can lead to quite some repetitive boilerplate. Thus I’d like to discuss if the above proposal would make sense to have in PureScript.

2 Likes

Hello and welcome.

Interesting idea. Can you maybe add some examples of mentioned boilerplate this would reduce?

2 Likes

This would also be useful when working with types with many type parameters

When writing a lib, it’s quite common to have a type which looks something like

data Foo a b c d e

Later in development, I might want to add one more type variable. Well have fun - none of the files compile and you have to make repetitive changes to every single function. Being able to pass all the type variables as a row would make this way nicer!

wouldn’t all those functions still have forall quantifiers for the used type-variables?

2 Likes

This way I could have a single type variable and then somehow access differents fields on it (not sure if that’s even possible, it would basically be typelevel records)

I think that’s a different problem. Even though interesting, I think it could not be solved with the proposal.

2 Likes

Sure. Have a look at this complete and compiling example:

1. Example

import Prelude

import Effect.Aff (Aff)
import Effect.Class.Console (log)
import Effect.Class.Console as Eff
import Node.Encoding (Encoding(..))
import Node.FS.Aff as FSA
import Node.FS.Sync as FS

type Env m =
  { readFile :: String -> m String
  , writeFile :: String -> String -> m Unit
  , log :: String -> m Unit
  }

mkEnvAff :: Aff (Env Aff)
mkEnvAff = do
  log "Creating the env..."
  pure
    { readFile: \path -> FSA.readTextFile UTF8 path
    , writeFile: \path content -> FSA.writeTextFile UTF8 path content
    , log: \msg -> Eff.log msg
    }

Let’s assume I make a mistake and implement writeFile like:
\path content -> FS.writeTextFile UTF8 path content (uses Effect instead of Aff)
Then the compilation error is not right in the place where the mistake happend, but instead in the line log "Creating the env..."

2. Example Refactored with Current PureScript

So what I need is better inference and one simple and way to get it is to provide more type signatures. So if my Env is growing I’d like to avoid those inaccurate compiler message (positions) and do the following refactor:

type Env m =
  { readFile :: Env_readFile m
  , writeFile :: Env_writeFile m
  , log :: Env_log m
  }

type Env_readFile m = String -> m String
type Env_writeFile m = String -> String -> m Unit
type Env_log m = String -> m Unit

mkEnvAff :: Aff (Env Aff)
mkEnvAff = do
  log "Creating the env..."
  let
    readFile :: Env_readFile Aff
    readFile path = FSA.readTextFile UTF8 path

    writeFile :: Env_writeFile Aff
    writeFile path content = FSA.writeTextFile UTF8 path content

    log :: Env_log Aff
    log msg = Eff.log msg
  pure
    { readFile, writeFile, log }

And if I do the mistake from above again, I get the error message right at the place where it occurs. However the types now need the boilerplate of defining each field in a separate type alias.

3. Example Refactored with the Proposal

So with the Indexed Access Type the whole example would look like:

type Env m =
  { readFile :: String -> m String
  , writeFile :: String -> String -> m Unit
  , log :: String -> m Unit
  }

mkEnvAff :: Aff (Env Aff)
mkEnvAff = do
  log "Creating the env..."
  let
    readFile :: Env["readFile"] Aff
    readFile path = FSA.readTextFile UTF8 path

    writeFile :: Env["writeFile"] Aff
    writeFile path content = FSA.writeTextFile UTF8 path content

    log :: Env["log"] Aff
    log msg = Eff.log msg
  pure
    { readFile, writeFile, log }

Hope that clarifies a bit how the proposal could be useful.

3 Likes

Yes that’s indeed a great example.

Thank you.

Interesting problem and I was thinking that you can actually do this with the existing type-level machinery of Purescript already. I was hacking with @i-am-the-slime a bit this morning and we actually came pretty close. This is the machinery around it:

class Accessor :: Row Type -> Symbol -> Type -> Constraint
class Accessor input sym output | input sym -> output

instance (RL.RowToList rIn rInRL, AccessorHelper rInRL sym output) => Accessor rIn sym output

class AccessorHelper :: forall rl. RL.RowList rl -> Symbol -> Type -> Constraint
class AccessorHelper inputRL sym output | inputRL sym -> output

instance AccessorHelper (RL.Cons sym outputTpe tail) sym outputTpe -- (RL.Cons "name" String (RL.Cons "age" Int RL.Nil)) "age" ?h
else instance
  ( AccessorHelper tailRL sym outputTpe
  ) =>
  AccessorHelper (RL.Cons symOther otherType tailRL) sym outputTpe
else instance
  ( Fail (Beside (Text "Symbol ") (Beside (Text sym) (Text " does not exist")))
  ) =>
  AccessorHelper RL.Nil sym Void

the :: forall r sym output. Accessor r sym output => Proxy r -> Proxy sym -> output -> output
the _ _ = identity

Now we can try this out with an

type MyRow = (name :: String, age :: Int)

name :: String
name = the (Proxy :: Proxy MyRow) (Proxy :: Proxy "name") "Hans" -- infers String

-- name2 = the (Proxy :: _ MyRow) (Proxy :: _ "nae") "Hans" -- error
-- name3 = the (Proxy :: _ MyRow) (Proxy :: _ "name") true -- error cannot match boolean with String

Unfortunately I needed the work-around with the the function (see below). With the upcoming visible type applications, this will become less-boilerplatey:


the2 :: forall (@r :: Row Type) (@sym :: Symbol) output. Accessor r sym output => output -> output
the2 = identity

name4 = the2 @MyRow @"age" 0 -- infers Int
--name5 = the2 @MyRow @"age" false -- error cannot match boolean with Int

So quite reasonable I think.

I was trying to write a type alias with a Constraint:

type Access row sym tpe = Accessor row sym tpe => tpe

then you could simply do:

age :: Access MyRow "age" _
age = 5

and let purescript infer the type.
However this doesn’t seem to work. Maybe some of the experts here can tell me why this doesn’t work?

2 Likes

I’m not sure this follows. Don’t you (and all of us rather need to fix this problem):

“the compilation error is not right in the place where the mistake happend”?

I thought @PureFunctor was on that.

You guys got pretty far! There’s a simpler way that also happens to work with the type alias syntax:

-- Common boilerplate:
class Accessory :: Type -> Symbol -> Type -> Constraint
class Accessory r s t | r s -> t

type Access r s t = Accessory r s t => t
instance Cons s t r' r => Accessory (Record r) s t

-- User code:
type Env m =
  { readFile :: String -> m String
  , writeFile :: String -> String -> m Unit
  , log :: String -> m Unit
  }

mkEnvAff :: Aff (Env Aff)
mkEnvAff = do
  log "Creating the env..."
  let
    readFile :: Access (Env Aff) "readFile" _
    readFile path = FSA.readTextFile UTF8 path

    writeFile :: Access (Env Aff) "writeFile" _
    writeFile path content = FSA.writeTextFile UTF8 path content

    log :: Access (Env Aff) "log" _
    log msg = Eff.log msg
  pure
    { readFile, writeFile, log }
3 Likes

Yes-ish, though my type-check-then-desugar branch doesn’t have JS code generation still. A fix that would take less detours is to change how type checking is done for function applications.

1 Like

@i-am-the-slime You’re right. Totally convincing. In my example ideally the extra signature would not be necessary if the compiler messages would be more accurate.

Apart from that, I still think there are many situation where you intentionally want to use a type signature that already exists on a field of some record type. E.g. you may want to define those functions top level where you have to add a signature (if you don’t want to get a warning). Usually in this case I see type signatures duplicated for this purpose or, as in my example the main type gets split up with type aliases.

However maybe one counter argument would be: Once you have the feature to access a field of a record type, maybe you want to do other similar things. Map over a record, change the type of a field, etc. This is how type level programming works in typescript and it’s quite different from type level programming with type class resolution. What I mean is, going in this direction would mean to open up a second way to do type level programming in PureScript which does not sound right.

While writing this I saw the post from @rhendric . And for a moment I thought I have to revoke what I just said. I did not know about the trick of using the the constrained type alias with a wild card. It seems like the missing link. The example compiles well but it seems that I don’t get those strong type signatures out of it. Whenever I introduce a mistake inside a function, the error does again appear somewhere else. If I replace the wildcard with a whole ?a I can see that the correct concrete type is inferred. However, polymorphic code inside the function body does not get resolved as nicely as if I’d write out the signature. It seems like the wildcard is somehow resolved quite late. But maybe there’s something that can be done here?

So after thinking about this again, maybe a more general way would be if the following would be allowed as a top level type alias:

type T = Access (Env Aff) "readFile" a => a

I cannot tell which kind of implication this would have. But I’d be curious to know if it may be conceptionally possible.

While this works (i.e. Try PS example of Ryan’s code), the outputted JS incurs an extra closure on each field due to the type class dictionary (i.e. JS output of above example).

3 Likes

@JordanMartinez
It works but it has some limitations:

Which both wouldn’t be the case if you write out the type signature