`typeof` syntactic sugar

I would like to suggest adding a typeof functionality to type definitions.


Example:

Here we use the same function type twice. (function bodies can be ignored).

f :: Int -> Int -> Int
f _ _ = 1

collectFs :: Array (Int -> Int -> Int) -> Int
collectFs _ = 1

With the proposal:

f :: Int -> Int -> Int
f _ _ = 1

collectFs :: Array (typeof f) -> Int
collectFs _ = 1

This could be syntactic sugar that resolves to:

type Typeof_F = Int -> Int -> Int

f :: Typeof_F
f _ _ = 1

collectFs :: Array Typeof_F -> Int
collectFs _ = 1

Motivation:

One advantage would be that you could take function types from packages without having to write them explicitly

import Some.Package ( f )

collectFs :: Array (typeof f) -> Int
collectFs _ = 1

myF :: typeof f
myF a b = 1

polymorphic types:

See this function

f2 :: forall a. a -> a -> Int
f2 x y = 1

collectF2s :: forall b. Array (b -> b -> Int) -> Int
collectF2s xs = 1

with the proposal:

f2 :: forall a. a -> a -> Int
f2 x y = 1

collectF2s :: forall b. Array (typeof f2 b) -> Int
collectF2s xs = 1

could be resolved to

type Typeof_F2 a = a -> a -> Int

f2 :: forall a. Typeof_F2 a
f2 x y = 1

collectF2s :: forall b. Array (Typeof_F2 b) -> Int
collectF2s xs = 1

Of course, it saves writing work. But above all, it saves me having to invent names for things like ‘Typeof_X’.

Furthermore, in the example with imported function:

import Some.Package ( f )

collectFs :: Array (typeof f) -> Int
collectFs _ = 1

If the package has been updated with a breaking change for f, an error occurs within collectFs because the type acts as a dependency.


Further variants of the wording

like

collectFs :: Array (like f) -> Int

see

collectFs :: Array (see f) -> Int

Thank you for reading.

Just a few thoughts…

First, I can see how this would be useful in that it removes some boilerplate one might otherwise write / removes the need to name something.

However and second, I can also see this as being a means of introducing bugs into a codebase. It depends on how powerful typeof is.

If typeof f works on types that include type class constraints, it’s probably possible that a change in f’s type signature can implicitly change which type class instance is used in typeof f’s usage. And the compiler would happily say everything is ok.

If typeof f doesn’t, then its expressive power is diminished, interfering with the motive of preventing the need to write boilerplate.

Third, how would you reference the identifier in such a way to distinguish it from type variables and Symbols?

foo :: forall f2. Proxy "foo" -> typeOf f2 -> f2

Fourth, typeof appears to be a possible “compiler-solved type application”. This concept doesn’t exist, but I’m using such terminology to describe what I have in mind. Assuming such a thing as this existed…:

type TypeOf :: Identifier -> Type
type TypeOf a = <compiler-solved type>

… one could write

f :: Int

foo :: TypeOf "f"

However, your above proposal includes polymorphic types, which indicates an n-ary function. So, perhaps the identifier itself could be type-applied? Reusing VTA syntax, I’d imagine something like this:

f2 :: forall a. a -> a -> Int
f2 x y = 1

collectF2s :: forall b. Array (TypeOf ("f2" @b)) -> Int
collectF2s xs = 1

If typeof is a unary operator and not an ordinary type constructor, this isn’t so bad. Type variables and term names are different namespaces; an identifier is resolved as a term in a term context and as a type in a type context. typeof could just introduce a term context within a type, just as (one of the uses of) :: introduces a type context within a term.

1 Like

Could you please give a short example for this problem.

Could you please give a short example for this problem.

I don’t think I can. And upon further thought, I believe I’m just wrong about this. Theoretically, typeof is just a language feature for nameless type aliases that correspond to some top-level identifier’s type signature. If the only difference here is that they are unnamed and their definition is tied to an existing identifier’s type signature rather than the one the user writes in named type aliases, then it should work the same as type aliases.

For context, I was thinking of what happened in Unbiasing the Semigroup instance for Map. But, comparing this situation to that one is wrong. That problem only arose because 'a change an instance implementation has subtle effects on existing code and the compiler doesn’t error. So, we needed a public post to raise awareness of the problem given how heavily it’s used. I thought that this proposed feature could suffer from a similar situation, but I think it’s more apt to describe it as being another potential avenue in which the above problem can occur.

But that being said, can typeof reference the identifier in which it’s used?

f :: typeof "f"
f x = x

I would assume not. However, one could argue that we could use the inferred type of f here. But what about when it references something else that’s part of a mutually-recursive group?

isEven :: typeof "isOdd"
isEven x
  | x == 0 = true
  | otherwise = not $ isOdd x

isOdd :: typeof "isEven"
isOdd x = isEven (x - 1)

Again, I assume not, but perhaps we could use the inferred type here?

1 Like

Really good example! I have to think about it for a while. But right now I think you’ve found an example that makes typeof-proposal impossible.

Why would it be impossible? We use a bidirectional type checker, so AFAIK, this isn’t impossible. Looking at just the body of both functions, we could infer the type to:

<identifier> :: Int -> Boolean

If we identified mutually-recursive groups like this and found that the type signatures reference one another, then so long as the type signatures match, things should check out.

However, I’d question why someone would do something this complicated in the first place. I’d opt for adding another constraint to prevent the above scenario:

  • a typeof usage must always eventually reference a complete type signature.

By “complete” I mean something the user has manually written out, whether that be via a type alias definition (i.e type Alias = <definition>) or a top-level identifier whose type signature makes no typeof usage.

So this wouldn’t work either?:

f1 :: Int -> Int
f1 _ = 1

f2 :: typeof f1
f2 _ = 2

f3 :: typeof f2
f3 _ = 3

I don’t know if it is hard or very hard to implement this, but I would change this definition to the following:

  • a typeof usage can not create circular/recursive type signatures.

Because:
A “complete” type signature would reduce the power of the typeof feature when it comes to imported functions from packages.

import ThirdPartyPackage (f)

myF :: typeof f

When f is defined with typeof, this would fail. And there’s nothing I can do about it, except write the type definition by hand again
I had the idea for typeof mainly because of imported functions.

Another question:

In the last example I imported f. But only for its type deifinition. Maybe I never use f in my code.
Does the implementation of typeof also require that unused functions that were only imported for typeof are removed from the generated JS?

I don’t see why it wouldn’t. f2 references f1 which has a “complete” type signature. f3 also references f1 by going through f2. So, f3 still references a “complete” type signature. In other words, solving it would work like this:

  1. Create a list of types to solve (e.g. find all usages of typeof):
    • found f2
    • found f3
  2. Resolve the identifier they reference. In the event that an identifier cannot be resolved to some module’s identifier, fail with an error.
    • f2 references f1 in module X
    • f3 references f2 in module Y
  3. Solve the typeof usage:
    • Follow references 1 step. If a “complete” type signature is found, solve it. If one is not found, then defer it:
      • f2 references a complete type signature, so solve it as f1’s type signature
      • f3 references a typeof usage, so defer its solution
    • Recurse on this step until one of the following occurs:
      • Solved - All typeof usages are solved and no deferred problems are found
      • Failed - if the list of deferred solutions is the same before and after a recursive step, then we cannot make progress. So fail.
  4. In the event of failure, analyze the deferred solutions and report a corresponding error for the situation, such as mutually-recursive typeof usage, etc.

Good question, but remember that JS is not the only backend to consider. There are two ways to resolve this:

  1. we add syntax for importing just the type signature, reusing typeof to indicate what kind of import this is: import Module (typeof f)
  2. we don’t add such import syntax and just import is as normal.

I believe the second one is more desirable as it reduces the amount of syntax one needs to learn and removes a situation where one wants to import f as a typeof identifier and f as a value to use in some term. Moreover, I’m pretty sure the compiler already removes unused imports in the outputted code, or at least, purs-backend-es does.

My 2p is I can’t really see myself using this feature, I’d define a type synonym explicitly instead if I wanted a simpler identifier for a function argument.

I understand the rationale, it is convenient, but I think it’d be slightly harmful to reading code when you come back to it or are new to it.

7 Likes

Coming from having written a bunch of Typescript (which has this feature) at work recently, there are some other novel angles that typeof can be used for that may be worth consideration here.

The big use for me personally has been with zod, a data validation/parsing library. With zod, there’s a nice pattern where, for reading values like:

const user = {
  id: 12,
  username: "alex",
}

You start by defining the term-level schema for the type:

import { z } from 'zod'

const User = z.object({
  id: z.number(),
  username: z.string(),
});

and then use typeof to define the data type in terms of the schema:

// typeof User ~evaluates to z.ZodType<{ id: number, username: string}>
type User = z.infer<typeof User> 
// equivalent to `type User = { id: number, username: string } `

const alice: User = { id: 1, username: "alice" }
const bob: User = User.parse(JSON.parse('{ "id": 2, "username": "bob"}'))

This by far my favorite approach to serial data validation/parsing code. It provides the heavily-demanded “only define your datatype once” behavior you find in typeclass/annotation based parsing libraries like aeson, serde, and jackson, but with the flexibility of hand-written parsers.

The general pattern to consider here is:

import Some.Fancy.Lib ( makeThingy, ExtractThingyAspect )

-- Has a complicated type I don't really want to write out
myThingy = makeThingy ...

type TypeRelatedToMyThingy = ExtractThingyAspect (typeof myThingy)

I would love to use this pattern with purescript-codec-argonaut, as an example :slightly_smiling_face:


Another thing I found myself liking this for is casting in terms of inferred types. Sample code:

// Typescript
function safeDivide(num: number, denom: number): number | undefined {
  if (denom === 0) {
    return undefined;
  } else {
    return num / denom;
} 


const result = safeDivide(20, 10);
// denominator is known to be non-zero at compile time, so this
// is a safe cast
// See: https://www.typescriptlang.org/docs/handbook/utility-types.html#excludeuniontype-excludedmembers
const nonOptionalResult = result as (Exclude<typeof result, undefined>)
// ...go use nonOptionalResult

Again, this is a contrived example (unfortunately can’t share the real stuff from work) and probably less relevant in Purescript. I hope to get across, though, that typeof has some nifty organic uses that I didn’t expect coming from a Haskell/Purescript background. I would really love to use it in Purescript too!

(edited for grammar, a bit of clarity)

1 Like

(Was limited by the new-user permissions in the prior post; here’s some additional links in case you’re not familiar with the other parsing libraries I mention: