Hacker News new | ask | show | jobs
by lhorie 2491 days ago
The problem IMHO is the old adage of the devil being in the details. I see a lot of engineers talking about things like deriving types from enums, and meanwhile the type system will merrily let you do this:

    type Foo = {a: number}
    const o: Foo = JSON.parse('null')
    o.a = 1
It feels like people are lulling themselves into a false sense of security by making increasingly complex self-consistence schemes via type utilities, but they just shrug when I point out that the foundation of the scheme is still unsound.
6 comments

I've run into issues with TypeScript that forced me to cast string literal types to themselves before it would compile. So literally this code:

    'x' as 'x'
Nothing else would work, we were simply forced to do that. Avoiding `any` in all cases is simply not possible, as Redux will require you to use it at least once as of the last time I used TypeScript, which wasn't that long ago. Type discoverability for libraries was such a massive hassle that I don't see how other TS devs have gotten around it. Not everyone can use Visual Studio.

I don't find TypeScript's type system to be anywhere close to sound. Instead, it feels like it lies to me a lot. At least it has HKTs now, so that's nice. But I ended up moving to ClojureScript (and a little PureScript) and I've never looked back.

FYI you can use 'x' as const nowadays.
TypeScript does not have higher-kinded types. I'd link sources but it's difficult to prove the negative here. It just doesn't have any.
> It feels like people are lulling themselves into a false sense of security by making increasingly complex self-consistence schemes via type utilities, but they just shrug when I point out that the foundation of the scheme is still unsound.

I don't agree with this premise. Assembly is an untyped language but you can build things on top of it like Rust or Haskell, or you can write in C and cast everything to a void. I do* agree that validating the types of values is a hard problem in any language and that JSON.parse should be typed as `unknown` these days (although there are also reasons why it should not do that due to casting), but it makes complete sense IMO. JSON.parse could return any valid JSON value. The compiler doesn't know the structure of the string provided beforehand.

The difference between something like Rust->ASM and TS->JS is that the former actually emits runtime machine code to deal with e.g. an Option, whereas in TS, the compiler is happy to emit the exact same runtime code for both `const o: Foo = JSON.parse('null')` and `const o: Foo | null = JSON.parse('null')` without throwing a compile error.

Rust makes it exceptionally "hard" to write unsafe code, by making it blindingly obvious when you're doing it, whereas in TS it can be very challenging to spot unsoundness, especially considering that the audience of the language is not type system scholars.

I won't argue that getting strong code in TS is easy but I definitely don't think it's as hard as you're making it out to be. That being said, it's more of a limitation of the underlying execution context in my opinion, and you have to realize that it's not just jS that's out to get you but the whole ecosystem. But that's the expectation in all languages, even in Rust. In production optimizations all bets are off and you're on your own if your app dies.

For example of what I mean by the platform being out to get you, the second parameter to JSON.parse is a recovery function. So it'll happily parse anything and return anything. Again, should be "unknown" but it was typed before unknown was a thing.

    JSON.parse('{ "fooDate": 123456789 }', (key, value) => !key.includes('Date') ? value : new Date(value));

    JSON.parse('123456789', (key, value) => new Date(value))
I actually just learned about this today, since I was doing some digging. Horrifying stuff for a typed language to get around. FWIW, we've made it an explicit lint error to use the `any` type, and enabled --noImplicitAny and --noImplicitReturns. That's been pretty solid for our codebase and because I/O is limited to a few edges, we can call out those edges for type guards.
As far as I don't like the "Angry Lisp Drunk Haskell" version of TS e.g. loads of `<` and '>' mixed with functional concepts from half of the Haskell, which makes understanding code harder than necessary.

Typescript is one of the rare good things in javascript. You have to understands JS, because there is always gun pointed at your foot, waiting to blow you away. Still TS makes this gun, a little bit harder to trigger. You cannot just write Java in it and cross your fingers.

Typescript gives you pretty good docs most of the time for free. Only problem is when author of code used `any` type or `Angry Lisp` version of it. Interfaces, enums and field access and typedefs are godsend.

In defense of TypeScript, your example looks like casting `void*` in C. Well typed `JSON.parse` would be pretty complicated and require runtime-machinery.

Only thing I dislike about TS is lack of proper `optional` types. I know the '.?' operator is coming, but still.

They have the unknown type since 3.0, which will force you to write runtime validation to satisfy the type checker.

It’s very helpful for dealing with untrusted/uncontrolled data.

> your example looks like casting `void *` in C

In practice, that's a pretty good approximation, except that in C, such a cast sticks out like a sore thumb, whereas something like `const o:Foo = getSomeDataSomehow()` looks the same for both a matching concrete type as it does for a cast from `any`.

The thing with type systems like Typescript/Flow is that there are both structural types and nominal types, and _some_ ability to refine nominal types based on their structures, but then people think they can extrapolate that limited capability to ends that the type system doesn't really support.

The return type of JSON.parse can very neatly be expressed with a recursive ADT (e.g. something like `type JSON = string | number | boolean | null | {[key: string]: JSON} | Array<JSON>`), and TS/Flow do have the ability to refine ADTs, e.g. `if (typeof x === 'string') x.toLowerCase()` is perfectly sound. Why that's not the default is a bit mind boggling IMHO.

What doesn't make sense is to assume one can cast a generic structural type to a random concrete nominal type using the "I know better than the compiler, let me cast" escape hatch mechanism, and then simultaneously omit the runtime refinement checks that they are responsible for writing, when they voided warranty through the cast.

But it's a heck lot harder to explain that they are writing unsound code in this case, because "hey look my type coverage is high, it must be sound!"

TL;DR: TS/Flow aren't silver bullets, but as usual, people tend to cling on to the brand as a social proof of "safety", rather than actually taking the time to understand what actual type safety is all about.

That's a very good point! Although I find the example to be rather exaggerated, I see how these types of errors can happen when you're dealing with very complex types. In the post, I also talk about how wrongly-typed dependencies will compromise type safety, which can be another source of errors.

Nonetheless, I think that TS can catch a lot of errors and will help documenting your code - and that's a very good thing on its own.

This is the equivalent of casting in Java. Is Java's type system unsound?
There is no casting in a sound type system.

No language actually gets there, but there are some where developers mostly don't even remember there is a cast operation.

You can't cast from a HashMap to an Animal class in Java, so in that sense Java is sounder. But you can still do `Animal a = null; a.walk();`, so in that sense, Java isn't sound.
Correct me if I'm mistaken: I think you can indeed cast from HashMap to Animal and it will happily compile:

```java

import java.util.HashMap;

class Main {

  class Animal {
    String sound = "roar";
  }

  public static void main(String[] args) {
    HashMap<String, String> map = new HashMap<>();
    Animal animal = (Animal) (Object) map;
    System.out.println(animal.sound);
  }
  
} ```

At runtime, this will crash with the following Exception: `Exception in thread "main" java.lang.ClassCastException: class java.util.HashMap cannot be cast to class Main$Animal`, but it will satisfy the type system.

Oh right, I totally forgot about casting to Object! So much for "sort of sound" haha
this is why I wish typescript added runtime checks in development. Worst case, it would throw a TS error in your browser any time a function call or a network request didn’t match what you typed
you don't really want the overhead of checking types on every call, you know where your applications entry points are - if you need checks add them.
but I can’t add checks to the couple places I let users upload json for a form builder, for instance. No way to validate json with typescript natively. That would be awesome