Strict, native type checker

There's been a lot of buzz around Microsoft's proposal for type-checking. At first, I was excited about the idea, but I'm starting to become more and more skeptical about it. My biggest issue, is that I simply think it would be healthier for the JavaScript community to simply have a single, official type system rather than a type-system syntax space where you can run any type-checker against it. (Plus, I'm becoming increasingly more convinced that it's not possible to provide a flexible syntax space that really is type-checker agnostic, and doesn't favor one type-checking style over another).

In their README, they alluded to the fact that it would be highly improbably for EcmaScript to adopt a standard type system, because EcmaScript operates under "sound checks", and TypeScript became famous by explicitly not having any sound checks in their system.

But, I think there's a major hole in their argument. TypeScript couldn't provide sound checks, at least, it wouldn't be easy to do so in a performant way using what they had available to them. If we standardize some official type-checking tooling that runs on developers machines, and marry that with certain runtime behaviors, we could end up with a really nice type system that's both fast and 100% sound.

I decided to take a stab at exploring the "sound type-system" space, to see what's out there and what problems we might run into. Certainly, there's also room to explore a standard, unsound type system (i.e. a types-as-comments proposal that's intended for a single, standard type-checker), and I think that would be an interesting conversation to have, but this won't be the focus of the proposal I'm presenting.

The proposal itself turned into something fairly large. Feel free to take a look over it here and then discuss it, either in this thread or in the issues on that repository.

Update: I also started a thread on Microsoft's type-annotations proposal. Some conversation has been happening there as well, so feel free to take a peek over there too.

4 Likes

Hey @theScottyJam !

Caveat: I haven't had a chance to read your alternative proposal in depth yet.

When I hear "strong type system" in EcmaScript, the first thing that comes to my mind is that this is only possible by creating an impenetrable barrier between existing code and new 'strongly typed' code. A strong type system loses all guarantees once it is directly combined with a non-strong system.

For example, if I have a value that is Array<Cat>, it would not be sound to use something from the existing JS ecosystem (e.g. Lodash) to operate on that value.

Languages like Elm get around this by having a tight message based system for integrating Elm code with 'the outside world', effectively requiring adapters to be written for each library that is used.

1 Like

Another way to do it, which is what was proposed, is to keep around some metadata describing what type it currently conforms to. You have to explicitly attach the metadata, and can only attach it if the data conforms to the correct shape, but once attached it'll stick with the value unless the value gets modified in such a way as to make it non-conforming, at which point it'll throw a runtime error.

So, you're able to pass in an Array<Cat> into Lodash, however, for it to stay as an Array<Cat> by the end of Lodash's execution, Lodash can't modify it in such a way as to cause it to break this interface. Lodash would be allowed to remove elements from the array, or add a new element that's tagged with the Cat interface, but it can't add a number to the array. (The Array class itself will help define some of these restrictions).

It's possible I'm incorrectly using the word strong and sound. What I meant by those terms is simply that, if you declare that a function received a value of type Cat, then that's what it's going to receive, and you can't get around the type-engine's back and get something else into the function. Runtime assertions will enforce it, and tagging will help make it performant. So, you're still allowed to use an existing library that hasn't been converted to be "strongly" typed yet, you just need to provide a declaration file explaining what the types of its functions are, and runtime assertions will make sure it behaves according to how you said you expected it to behave.

Still, your concerns are certainly valid. It's possible to work with libraries that are untyped, but there can be a bit of unavoidable performance overhead in trying to make sure it really does follow, at runtime, what you expect it to do. If you're dealing with a lot of untyped libraries, it might be better to just not add types to your JavaScript code yet.

2 Likes

I think I would like to see decorators being extended to function/method parameters and variables to be able to perform run-time validations. There shouldn't be a need for yet another syntax.

In that sense I'm supportive of type annotations that have no run-time impact as they'd be orthogonal to decorator based runtime checks. Decorators being functions could also help with type inference if they themselves have type annotations.

3 Likes

@mhofman - hmm, interesting. I guess you're right that decorators could be used to achieve a good portion of the runtime checks (especially if they're built-in decorators). Though, how would you propose that we achieve features such as interfaces, or generic types? Perhaps interfaces could still be a new syntax, that simply creates a decorator? And, I'm not sure how we would deal with generics.

Some examples to toy with:

// Decorating a declaration (is this what you have in mind for declarations?)
@Types.number const x = 2
@Types.number let x = 3
x = 'hi there' // The decorator is able to turn this into a runtime error?

// Example decorating a function.
// First, you specify a list of parameter, then the return value.
@Types.function([Types.number, Type.string], Types.boolean)
function myFunction(someNumb, someString) {
  return true
}

// Example interface
interface Point {
  x: Types.number,
  y: Types.number,
}

@Point
let x = { x: 2, y: 3 }

I guess static type checkers could be created to understand these decorators.

Some things, like union types could still be a little verbose:

@Types.function([Types.untion(Types.number, Types.string)], Types.number)
function fn(eitherANumberOrAString) { return 2 }

Granted, if I didn't namespace everything with Types, it wouldn't be so bad, but a name like function is a reserved keyword, and Function is a class, not a decorator, so we would probably need some sort of namespacing with some of these names.


On another note, I wonder if you would be more ok with adding new type syntax, if we didn't try to mix type syntax into the same place with parameters can go. After all, with type synax, you can make a single parameter into quite a noisy ordeal.

function fn(@doThing x :: number = 2) { ... }

Instead, we could follow the paths of other languages, and make it so you're only allowed to provide types to a value on the preceding line. This should make for pretty simple syntax rules, and keep code a little less noisy.

:: (number) => void
function fn(@doThing x = 2) { ... }
2 Likes

Some downsides to this are:

  • as the number of arguments grows it becomes harder to keep them in sync with the types
  • harder to add types to inline callbacks
1 Like

Oh yeah, that's right :) - I remember bumping into that as I was playing around with this type system syntax in an example project.

1 Like

@aclaymore

So, I was wrong to use the term "sound", it would have been more accurate for me to simply say I was trying to make a stricter type system.

What you brought up about calling code provided by third-party libraries did also help me realize that there is a deep flaw in the current proposal. I though I had handled this situation, but it turns out I haven't when it comes to mutable object types.

interface User {
  name :: string
}

async function main() {
  const myUser :: User! = { name: 'Sally' }
  someThirdPartyFn(myUser)
  console.log(myUser.name) // How can we be sure myUser still has a name property here?
  await something
  console.log(myUser.name) // Or here? The third-party function could
                           // have a reference to this object, that they randomly
                           // mutate whenever control goes to the event loop.
}

I did find some ways to still bring some strong guarantees and type-safety, even to this sort of scenario, but ultimately decided to put it aside for now, as it brought a ton of complexity into the proposal, and would likely cause the type system to get in the way more often then help.

So, I'm thinking I'm going to change objectives a bit, and say that I'm simply trying to provide a type system that balances strictness with ease-of-use.

On another note, I did bring up this topic on Microsoft's type-annotation proposal, and a separate conversation had started over there about this. So now conversation is split between two places, whoops. Counter proposal: Is a strong type system really impossible for JavaScript? · Issue #136 · tc39/proposal-type-annotations · GitHub

1 Like

If the decorator proposal is already at stage 3 wouldn't it be possible to utilise it along with Microsoft's types as comments proposal to enable optional type checking?

something like in jsDocs:

@tscheck
function greet(word: string) {
    return word + " world!"
}

@tscheck
let word: string = greet("hello")

This won't really help with Interfaces, Enums, etc. Python decorators have the capability to do this sort of checks, correct me if I'm wrong!

No, because you're not able to get the types at runtime (at least in their current iteration you can't). They're acting as comments. So, it would be the same as trying to make the decorator do an assertion with a function defined like this:

@tscheck
function greet(word /* string */) {
    return word + " world!"
}

How can @tscheck know that there was a comment there that contained string?

1 Like

Ok that's right but can't we propose having type hints as meta data inside of a decorator?

type Decorator = (value: Input, context: {
  kind: string;
  name: string | symbol;
  access: {
    get?(): unknown;
    set?(value: unknown): void;
  };
  typeHint: string;
  isPrivate?: boolean;
  isStatic?: boolean;
  addInitializer?(initializer: () => void): void;
}) => Output | void;

This would mean that decorators should also be allowed to work with variable declarations;

But how would you make the top level scope / global scope type checkable??

// may be
@tscheck "use strict";

For all of this to work however, we need to expand the decorators proposal a bit more😅

1 Like