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.