Runtime types (and many other functional ideas)

I know JavaScript is not inherently a type-safe language, nor do I expect it to suddenly become one, but I do think there's a whole lot of nice features that we're missing out on because we don't have a type system. So, I propose we add a very minimal type system, one that's just powerful enough to open us up to the potential for a number of useful language features, but not so powerful as to kick TypeScript out of a job (for one, it'll be inferior to typescript because it'll be an entirely a runtime feature, which means there's a performance penalty, and for two, this type system won't be powerful enough to support things like generics, generics shouldn't be needed for it's purposes).

Here's how it works.

Whenever you're doing an assignment, you can use an "of" pseudo-operator that takes a pattern-matching expression on the right-hand side. The pattern is the type. If it fails to match the pattern, an error will be thrown. See the pattern matching proposal for more info.

const user = { name: 'Sally', age: 12 }

const user2 of { name } = user // works
const user2 of { xyz } = user // throw an error

We could then expand pattern-matching so that each class automatically provides a Symbol.matcher, with a definition that utilizes whatever mechanism is decided on for class brand checking from this class brand checking proposal (which in summary, this brand checking proposal just provides a more robust instanceof check. If this proposal doesn't go through, we can use instanceof instead). We could also provide primitive matches, as described in this issue. Together, this would provide the facilities to easily do brand checking with pattern matching, or this "of" pseudo-operator.

class User {
  constructor({ name, age }) {
    this.name = name
    this.age = age
  }
}
const user = new User({ name: 'Sally', age: 12 })

// user must be of type User
const user2 of User = user

// The user must have a name of type string, and an age of type number
const user3 of { name of Types.string, age of Types.number } = user

function getName(user of User) { ... }

By itself, this is pretty interesting, but the real power comes from the potential future proposals a type-checking mechanism like this enables. I'll enumerate a sample of some pretty nice options that we could look into.

Structs

A struct is basically a template for a record (from the record/tuple proposal) who's instances are tied to the struct they were created from for type-checking purposes. You define one as follows

struct User #{
  name, // type information is optional
  age of Types.number, // But you can add it if you want
}

And you create an instance of a struct as follows:

const myUser = User #{ name: 'Sally', age: 22 }
const myUser = User #{ name: 'Sally', age: 'not a number' } // throws

Similar to classes, these structs will automatically have a Symbol.matcher defined on them.

const myUser = User #{ name: 'Sally', age: 22 }

const myUser2 of User = myUser // works
const myUser3 of User = #{ name: 'Sam', age: 3 } // Fails, not a User.

match myUser {
  // Both class and struct instances will have their Symbol.matcher defined such that you can also pattern match on their contents using "with".
  when (User with { name }) {
    name
  }
  ...
}

Type Classes

This is inspired by haskell's type class feature. It solves a very similar problem to the protocol syntax proposal, but type classes can be thought of as a little more functional, while protocols are a little more OOP. The difference being, with protocols, you attach custom behaviors to each object via symbols, which means you can't use protocols on things like records. With type classes, you specify custom behaviors on types.

Here's an example type class definition

type class comparison for T {
  function lessThan(x of T, y of T)
  function equals(x of T, y of T)
  // This provides an optional, default implementation.
  function greaterThan(x of T, y of T) {
    return !lessThan(x, y) && !equals(x, y)
  }
  function compareWithInt(x of T, y of Types.number) {
    ...
  }
}

Here we're defining a type-class called "comparison". Its contents can be read sort of like a parameterized interface, T being the type parameter. Anyone who's wishing to make a type that conforms to this type class will need to provide function definitions that conform to this interface. The actual function definitions found within the type class become real functions in the local scope, but by default, they'll always throw an error no matter how they get called. You need to provide implementations for this type class for its corresponding functions to be of any use. Here's how you might go about doing this:

struct Box #{
  contents of Types.number
}

implement comparison for Box {
  lessThan(x, y) { return x.contents < y.contents }
  equals(x, y) { return x.contents === y.contents }
}

lessThan(Box #{ contents: 2 }, Box #{ contents: 4 }) // true

Here we're creating a Box struct, then we're providing function definitions for the comparison type class. We're basically saying "If you call the lessThan function with a Box instance, then this is how the lessThan function should act. Similarly, if you call the equals function with a Box, then use this definition. I'm not bothering to tell you how to do greaterThan or compareWithInt, use your default definitions for those".

Thus, on that last line, you can see that we're able to now correctly call the lessThan() function.

Here's a more complete example:

struct NumbBox #{ contents of Types.number }
struct StringBox #{ contents of Types.string }

numbBox1 = NumbBox #{ contents: 1 }
numbBox2 = NumbBox #{ contents: 2 }
stringBox1 = StringBox #{ contents: 'Hello World!' }
stringBox2 = StringBox #{ contents: 'Hello World!' }

type class equality for T {
  function equals(x of T, y of T)
}

equals(numbBox1, numbBox2) // Error!

implement equality for NumbBox {
  equals(x, y) {
    return x.contents === y.contents
  }
}

implement equality for StringBox {
  equals(x, y) {
    return x.contents === y.contents
  }
}

equals(numbBox1, numbBox2) // false (not an error anymore)
equals(stringBox1, stringBox2) // true

Variants

Variants are a much-loved feature among functional programmers, and now it would be possible to provide nice syntax support for them in JavaScript.

Here's an example of how you might define a variant

variant Action {
  Move #{ x of Types.number, y of Types.number }
  Quit #{}
}

Here we're defining an Action variant with two possible choices - Move and Quit. Move and Quit are actually just struct definitions. The only additional thing that this variant syntax provides is the ability to perform type checking against the variant container itself (in this case, "Action"). It's also very easy for a language like TypeScript to understand what you're trying to do with this variant, and they can provide extra type support when a variant is seen. Because variant values are just structs, you can simply create instances like this:

const moveAction = Action.Move #{ x: 2, y: 3 }
const quitAction = Action.Quit #{}

And you can perform type checking against variants like this:

function runAction(action of Action) { ... }

const myMoveAction of Action.Move = moveAction

And here's an example of pattern matching against variants. TypeScript, linters, or other tooling could additionally ensure that you always pattern match against all variant options.

match someAction {
  when (Action.Move with { x, y }) { ... }
  when (Action.Quit) { ... }
}

Conclusion

There's a whole lot to take in here. The main thing I'm hoping to focus on in this thread is the viability of having some sort of type-checking semantics, like "of" in the language. The rest of this content is meant to spark excitement about the possibilities that open up if we find that it is indeed possible to have this sort of type system in JavaScript (without the context of these extra features, I don't know if I would be on board with a type system in JavaScript either). If this thread seems to go in a positive direction, then I can open up separate threads to discuss each of these ideas individually.

1 Like

First of all, I'd shy away from overloading of. While it totally makes sense to think of a type as a set of values (as in "one of a kind"), thanks to for..of, in JS of somewhat implies the set is iterable (as in "one of our users").

ad Structs

The last line is somewhat surprising; contrast that with TypeScript which defines types by shape.

Plus it seems to me you could almost do this kind of thing in userland.

// define a "struct type"
const User = Struct(#{ name: Types.string, age: Types.number });
// Struct would return a callable acting as constructor and type checker

// create instance
const myUser = new User(#{ name: 'Sally', age: 22 })
const myUser = new User(#{ name: 'Sally', age: 'not a number' }) // throws

// assign after type check
const myUser2 = User(myUser) // works
const myUser3 = User(#{ name: 'Sam', age: 3 }) // Fails, not a User.

That's fine. I'll probably stick with using "of" for now, but it can always be bikeshedded later (e.g. we could maybe use another keyword, or an "->" token).

A lot of this stuff could be emulated to some degree or another in userland with varying degrees of success, but it's pretty nice to have the language provide these sorts of tools right off the shelf. Especially if you consider the fact that the concept of the struct can then be reused for other language features, like variants.

This, however, is probably my favorite part of the struct :) (and it's something that your userland version can't emulate easily). Let me show why a strict identity check is useful.

struct User #{
  name of Types.string
  birthday of Types.string
  age of Types.number
}

export const createUser = ({ name, birthday }) => User #{
  name,
  birthday,
  age: getAgeFromBirthday(birthday)
}

export const isUser = maybeUser => match maybeUser {
  when (User) { true }
  else { false }
}

What's important about this piece of code is that the User struct is never actually exported. Only the createUser and isUser functions gets exported. This means the only way to create an instance of User is via createUser(). You can create a similar User object that looks and acts like this User in almost every way, but this fake user can never pass the isUser() check that's also being exported.

In other words, this is a way to achieve encapsulation. The only ones who have authority to create a User are those who have lexical access to the User struct, everyone else must use the API provided by this module. This also provides the end-user with certain guarantees, like, the end user knows that they'll never receive a user object who's birthday and age are out of sync, because the code which generates these objects will always ensure that they're in sync. Random people can't go passing in fake, bad user objects.

I'll also point out that, yes, this sort of behavior is unnatural for typescript users, but it's also very natural for users with background in almost any other type-safe language, including other languages that compile down to JavaScript, like re-script/resonML and Elm.

2 Likes

Yes it can, e.g. by having every constructed instance in a WeakMap.

That doesn't actually work. WeakMap keys must be objects. Records are primitives.

I'm sure there are ways to make it somewhat work, it's just not an easy or user-friendly task.

Edit: Actually, I'm not so sure it's that possible. I can't think of any way to do it.

1 Like

The plan is to allow Records as WeakMap keys if they contain a Box with identity.

Ah, interesting

if they contain a box with an identity

That means the record has to have a symbol, or boxed object as a value, correct? This means the WeakMap doesn't provide any extra protection, as the identity it's relying on is public knowledge.

e.g. I could call createUser(), get a user record that has a unique symbol for one of its values. The createUser() API could store a boxed version of this record in a WeakMap. And... well, I guess I could forge a new record that is identical to the one I'm currently holding using that symbol, but I guess I couldn't do anything else. So, maybe this would be a viable option. Except for the annoying fact that I have to add a dummy symbol to the record to make this work, and that it's not very erognomic to use, but yeah, that's certainly an option.

1 Like

There was also a class-record idea, in this records could even have private fields which can serve as a brand check.

Official issue: Classes for Records Β· Issue #119 Β· tc39/proposal-record-tuple Β· GitHub
There was some more discussion somewhere else too but I can’t find the link

1 Like

Ah, yes, that's pretty close to what I would want from structs.

I guess the idea of struct itself doesn't necessarily need to have a simplified type system to work, a type system would just improve it. But, simply declaring the required fields, like what's done in that issue would certainly take us a long ways there.

Something similar could be said for the variant idea I shared, as that idea is mostly a collection of interconnected structs. It's certainly improved if there was a type-checking syntax, but it could stand alone without it, especially if we still automatically created a Symbol.matcher for the variant and its values.

In general, I think a lot of those ideas are able to stand by themselves to a degree, but definitely work better when designed together.

2 Likes

Incorrect - the inner box is what guarantees the record's uniqueness. Unless you have references to every box within the record (which you do get by virtue of having a reference to the record, BTW), you cannot construct a record equivalent to it. So it's still unique enough for use within weak maps.

1 Like

Thanks everyone for the feedback.

I decided to take a stab at making a separate thread for the individual ideas I originally presented. I started by taking the struct idea and trying to distill it down to its core. It sort of turned into something a little different (tagged records), but it keeps many of the same benefits as the struct presented here. You can see the new thread here. The tagged record idea naturally provides the ability to have variant-like behavior without any extra work, so variants got rolled into that topic as well.

This leaves us with the type class idea. This idea seems to be the most tied to a runtime type system, and I'm not sure I would be able to separate it, also, of all of the syntax suggestions I originally gave, the type classes seemed to be the heftiest, and I'm not sure it would be able to carry its own weight. This will be something that I'll keep thinking on, but I believe this kind of polymorphism would need to be achieved in a very different way in JavaScript, and perhaps Haskell's approach isn't the best fit for JavaScript.

The runtime type system idea itself also feels like something that will likely not get very far. It certainly would be nice to have, and I would vote for it, along with the other syntax suggestions I originally gave, but it's a pretty big shift in direction for JavaScript, and I'm not sure something like that would ever get through.

1 Like