(评论)
(comments)

原始链接: https://news.ycombinator.com/item?id=41064351

TypeScript 是 JavaScript 的超集,可为 JavaScript 代码提供强大的类型检查和更好的自动完成功能。 由于 TypeScript 的类型系统不健全,当发布到 NPM 存储库时,它需要单独的 TypeScript 组件及其相应的 JavaScript 组件的包。 为了解决这个问题,TypeScript 创建者建议将 TypeScript 组件捆绑在一个包中,为用户提供三年稳定的 TypeScript 版本,并在需要时能够独立升级 TypeScript 转译器。 该提案旨在确保用户不会因依赖关系而被迫使用过时的 TypeScript 版本,同时仍然为现有 TypeScript 安装提供向后兼容性。 此外,他们还讨论了类型剥离的实现细节,提出了一种最小语法,可以忽略“as”关键字之后的所有内容,以保持与 TypeScript 类型系统设计的向后兼容性。 此外,他们还讨论了各种符号和符号对的使用,质疑某些配对的必要性,并考虑使用替代符号来提高可读性和易于编码。 最后,他们反思了编程语言中配对的局限性,讨论了引入不寻常符号的潜在缺点以及大括号和方括号等常用符号提供的便利。 总的来说,这篇文章强调了在 TypeScript 这样的语言中实现强类型检查的好处和挑战,并强调了平衡向后兼容性、易用性和健壮类型检查的重要性。

相关文章

原文


One thing to note is that it is impossible to strip types from TypeScript without a grammar of TypeScript. Stripping types is not a token-level operation, and the TypeScript grammar is changing all the time.

Consider for example: `foo < bar & baz > ( x )`. In TypeScript 1.5 this parsed as (foo (x)) because bar&baz wasn’t a valid type expression yet. When the type intersection operator was added, the parse changed to foo<(bar & baz)>(x) which desugared to foo(x). I realise I’m going back in time here but it’s a nice simple example.

If you want to continue to use new TypeScript features you are going to need to keep compiling to JS, or else keep your node version up to date. For people who like to stick on node LTS releases this may be an unacceptable compromise.



It looks like the team has already considered this in one regard

> There is already a precedent for something that Node.js support, that can be upgraded seperately, its NPM. Node bundles a version of npm that can upgraded separately, we could do the same with our TypeScript transpiler.

> We could create a package that we bundle but that can also be downloaded from NPM, keep a stable version in core, but if TypeScript releases new features that we don't support or breaking changes, or users want to use the new shiny experimental feature, they can upgrade it separately. This ensures that users are not locked, but also provides support for a TypeScript version for the whole 3 years of the lifetime of Node.js release.

https://github.com/nodejs/loaders/issues/217



As long as Node understands to use the project-specific version of TypeScript (i.e., the one in node_modules or the PNP equivalent), that should be fine.

But it would be a step backward to need to globally upgrade TypeScript (as you do with npm), since some older projects will not be compatible with newer versions of TypeScript.

Ask me how I know. ;)



> As long as Node understands to use the project-specific version of TypeScript

It won't, but in such a scenario, typescript would only be a type checker, wich is a entirely different endeavor than running typescript.



The syntax from the perspective of type stripping has been relatively stable for more versions of Typescript than it was unstable. You had to reach all the way back to 1.5 in part because it's been very stable since about 2.x. The last major shift in syntax was probably Conditional Types in 2.8 adding the ternary if operator in type positions. (The type model if you were to try to typecheck rather than just type-strip has changed a lot since 2.x, but syntax has been generally stable. That's where most of Typescript's innovation has been in the type model/type inferencing rather than in syntax.)

It's still just (early in the process) Stage 1, but the majority of Typescript's type syntax, for the purposes of type stripping (not type checking), is attempting to be somewhat standardized: https://github.com/tc39/proposal-type-annotations



This is true, but in other cases they added keywords in ways that could work with type stripping. For example, the `as` keyword for casts has existed for a long time, and type stripping could strip everything after the `as` keyword with a minimal grammar.

When TypeScript added const declarations, they added it as `as const` so a type stripping could have still worked depending on how loosely it is implemented.

I think there is a world where type stripping exists (which the TS team has been in favor of) and the TS team might consider how it affects type stripping in future language design. For example, the `satisfies` keyword could have also been added by piggy-backing on the `as` keyword, like:

    const foo = { bar: 1 } as subtype of Foo
(I think not using `as` is a better fit semantically but this could be a trade-off to make for better type stripping backwards compatibility)


It can’t strip what’s after the as keyword without an up-to-date TS grammar, because `as` is an expression. The parser needs to know how to parse type expressions in order to know when the RHS of the `as` expression ends.

Let’s say that typescript adds a new type operator “wobble T”. What does this desugar to?

    x as wobble
    T
Without knowing about the new wobble syntax this would be parsed as `x as wobble; T` and desugar to `x; T`

With the new wobble syntax it would be parsed as `x as (wobble T);` according to JS semicolon insertion rules because the expression wobble is incomplete, and desugar to `x`



The “as” expression is not valid JavaScript anyway, so the default rule for implicit semicolon does not apply. A grammer for type expressions could define if and how semicolons should be inserted.



I don't know a lot about parser theory, and would love to learn more about ways to make parsing resilient in cases like this one. Simple cases like "ignore rest of line" make sense to me, but I'm unsure about "adversarial" examples (in the sense that they are meant to beat simple heuristics). Would you mind explaining how e.g. your `as` stripping could work for one specific adversarial example?
    function foo() {
        return bar(
            null as unknown as T extends boolean
            ? true /* ): */
            : (T extends string
                ? "string"
                : false
            )
            )
    }

    function bar(value: any): void {}
Any solution I can come up with suffers from at least one of these issues:

- "ignore rest of line" will either fail or lead to incorrect results - "find matching parenthesis" would have to parse comments inside types (probably doable, but could break with future TS additions) - "try finding end of non-JS code" will inevitably trip up in some situations, and can get very expensive

I'd love a rough outline or links/pointers, if you can find the time!

[0] TS Playground link: https://www.typescriptlang.org/play/?#code/AQ4MwVwOwYwFwJYHs...



CSS syntax have specific rules for how to handle unexpected tokens. E.g if an unexpected character is encountered in a declaration the parser ignores characters until next ; or }. But CSS does not have arbitrary nesting, so this makes it easier.

Comments as in your example is typically stripped in the tokenization stage so would not affect parsing. The TpeScript type syntax has its own grammar, but it uses the same lexical syntax as regular JavaScript.

A “meta grammar” for type expressions could say skip until next comma or semicolon, and it could recognize parentheses and brackets as nesting and fully skip such blocks also.

The problem with the ‘satisfies’ keyword is a parser without support would not even know this is part of the type language. New ‘skippable’ syntax would have to be introduced as ‘as satisfies’ or similar, triggering the type-syntax parsing mode.



Most parsers don't actually work with "lines" as a unit, those are for user-formatting. Generally the sort of building blocks you are looking for are more along the lines of "until end of expression" or "until end of statement". What defines an "expression" or a "statement" can be very complex depending on the parser and the language you are trying to parse.

In JS, because it is a fun example, "end of statement" is defined in large part by Automatic Semicolon Insertion (ASI), whether or not semicolons even exist in the source input. (Even if you use semicolons regularly in JS, JS will still insert its own semicolons. Semicolons don't protect you from ASI.) ASI is also a useful example because it is an ancient example of a language design intentionally trying to be resilient. Some older JS parsers even would ignore bad statements and continue on the next statement based on ASI determined statement break. We generally like our JS to be much more strict than that today, but early JS was originally built to be a resilient language in some interesting ways.

One place to dive into that directly (in the middle of a deeper context of JS parser theory): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...



Thanks for the response, but I'm aware of the basics. My question is pointed towards making language parsers resilient towards separately-evolving standards. How would you build a JS parser so that it correctly parses any new TS syntax, without changing behavior of valid code?

The example snippet I added is designed to violate the rules I could come up with. I'd specifically like to know: what are better rules to solve this specific case?



> How would you build a JS parser so that it correctly parses any new TS syntax, without changing behavior of valid code?

I don't know anything about parsers besides what I learned from that one semester worth of introduction class I took in college but from what I understand of your question, I think the answer is you can't simply because we can't look into the future.



You would also have to update your compiler. I guess you could phrase this as: you can't update your TS versions independently from your node.js version. But that's probably not an issue.



It’s an issue because node has a system of LTS releases, whereas TypeScript has quarterly updates, so the release cadence is different.

Updating node is much more fraught than updating TypeScript. For example, it may break any native code modules. That’s why users are directed to use the LTS and not the most recent release, so that there’s enough time for libraries to add support for the new version.

On the other hand, I usually adopt a new TypeScript version as soon as it comes out.



I made it sure to decouple the transpiler from node itself, the transpiler is in a npm package called amaro, that is bundled in node. The goal is to allow user to upgrade amaro indipendently so we dont have to lock a ts version for the whole lifespan of a release



TypeScript feels "boring" enough at this point that being a few years behind isn't gonna be an issue in most cases. For teams who want to stay on the absolute latest release of TypeScript but want to be more conservative with their Node version, external compilation will remain necessary; but for someone like me, where TypeScript has been "good enough" for many years that I'm not excited by new TypeScript releases, this feature will be really nice.

("Boring" in this context is a compliment, by the way)

EDIT: Though reading other comments, it seems like you can update the typescript stripper independent of node? That makes this moot anyway



But at the same time, it's good enough and has been good enough for many years. It's like how I'm sure EcmaScript 2024 contains cool new stuff, but if node only supported ES6, I would have no trouble writing ES6.



Though I'd primarily see this as a feature for the REPL or manual scripts where I'm not going to mind doing a `nvm use X`.

For production use, I'd still put my TS files through a build pipeline as normal



tangential protip: if you're using nvm to manage node versions, take a look at fnm as a superior replacement. (It can read the same .nvmrc file to switch on cd into a given dir, but it's faster and "cleaner" wrt impact on your shell.)



Not necessarily.

With a couple exceptions (like enums), you can strip the types out of TypeScript and end up with valid JS. What you could do is stabilize the grammar, and release new versions of TypeScript using the same grammar. Maybe you need a flag to use LTS grammar in your tsconfig.json file.



> With a couple exceptions (like enums)

Apart from a clueless engineer attempts them, it's been working out fine to pretend they don't exist.



Mistake isn't the right word. It's just a tradeoff.

There was no perfect solution available, so a tradeoff was necessary. You can disagree with this particular tradeoff, but had they gone another way some people would disagree with that as well. To be a mistake there would have had to have been an option available that was clearly better at the time.

Anyway, the idea that TS 5 should be backwards compatible with TS 1 is probably a bad one. Personally, I think packages with wide usage break backwards compatibility far too easily -- it puts everyone who uses it on an upgrade treadmill, so it should be done very judiciously. But even I wouldn't argue that TS 1 should have been its final form.



I’ll flip this around… reusing comparison as angle brackets is the mistake. C++ ran into some issues too.

I think Rust made the really smart move of putting :: before any type parameters for functions. Go made the good move of using square brackets for type parameters.



The problem can be traced back to ASCII/typewriters only including three sets of paired characters, plus inequality signs, which is not enough for programming languages.

We really need five sets: grouping, arrays/indexing, records, type parameters, and compound statements. Curly braces {} are also overloaded in JS for records and compound statements, leading to x => {} and x => ({}) meaning different things.

Square brackets wouldn't work for parametric functions because f[T](x) already means get the element at index T and call it.



Paired characters...

That's an interesting topic. Do you happen to know if UTF-8 contains more "pair" characters? In Latex we call these delimiteres, but that's just my limited experience coming in from math side. I tend to agree that it would be helpful to have more kind of nesting/pairing/grouping/delimiting characters. The problem is my imagination is limited to what I know from the ASCII world, and so it goes... no idea what new sets would look like.



So many different pairs are available. I like Asian corner brackets 「」and French guillemets « ». The angle brackets 〈〉 are popular in CS/math papers I think, though they might be confused with <>.



I have had access to « » before, which led me to doing a double take when I first encountered a

Which brings us to the philosophical question about paired characters: If we were to pick paired characters from a key set not available on everyone's keyboard, why must the paired characters even be used in real languages? Is that not actually actively detrimental when we end up needing the characters for real? Is this not why we even have escape characters to begin with?

Plus, must they be a part of anyone's real keyboard to begin with? What makes 「」any more valid than ¿? Could we not have saved ourselves a lot of mental strain if we solved it earlier on with a full set of truly uncommon characters?

I can imagine an alternate history, where some programming language in the late 70's made their editors with simple shortcuts (such as Ctrl+A for "array block") to input barely-if-ever-used yet low code character such as † or ‡, which would never be used outside of a string. And nowadays, with modern IDE's, we wouldn't even see those characters half the time. It would be syntax sugar, with blocks types stated in gutters and data types represented with colors or text.



Five sets, but at any given place in the syntax, not all five are possible. (I would add function calls to the list—so, six.)

In most languages, (grouping and compound statements) cannot syntactically appear in the same place as (indexing, records, type parameters, function calls). So we are immediately down to four.

Rust takes the approach that you use :: before type parameters, so they are easily distinguished from comparison operators at a syntactic level.

Go takes the approach that [] is just fine for type parameters—which seems pretty reasonable to me. In Go, there’s nothing that can be both indexed *and* take a type parameter.



> In Go, there’s nothing that can be both indexed and take a type parameter.

True, but TypeScript has a rule against type-dependent emit - it’s not allowed. Code must always do the same thing regardless of what the types are. And in any case JavaScript does allow indexing on functions, since functions are just objects.



Everytime a standardization happens, part of human creativity gets suppressed. Before ASCII, people were inventing all sorts of symbols and even the alphabet was flexible to changes. After ASCII, we got stuck with a certain set of letters and symbols. Heck, even our keyboards haven't changed that much since then. I really think we need more symbols than just &@$#%^*}{][<>()/\_~|



It's not a TypeScript mistake.

You could argue that it was a C++ mistake. It makes parsing harder, but otherwise seems to work as expected, so I don't consider it a mistake, but you could at least argue that way.

But regardless if it was a mistake in C++, it's now a complete standard, used in C++, Java, C#, and other languages to denote type parameters.

I would argue that it would have been a mistake to break that standard. What would you have used, and in what way would that have been enough better to compensate for the increased difficulty in understanding TypeScript generics for users of almost every other popular language?



It's definitely the right choice for Typescript. You could have gone the Scala route and used [] for generics, but that is so heavily used in ts/js as arrays it would not have made any sense.



I think the kind of teams that always stay on top of the latest TypeScript version and use the latest language features are also more likely to always stay on top of the latest Node versions. In my experience TypeScript upgrades actually more often need migrations/fixes for new errors than Node upgrades. Teams that don't care about latest V8 and Node features and always stay on LTS probably also care less about the latest and greatest TypeScript features.



I work on a large app that’s both client & server typescript called Notion.

We find Typescript much easier to upgrade than Node. New Node versions change performance characteristics of the app at runtime, and sometimes regress complex features like async hooks or have memory leaks. We tend to have multi-week rollout plans for new Node versions with side-by-side deploys to check metrics.

Typescript on the other hand someone can upgrade in a single PR, and once you get the types to check, you’re done and you merge. We just got to the latest TS version last week.



It's already the case for ECMAScript and I don't see why TypeScript should be treated differently when Node.js has to transpile it to JavaScript and among other things ensure that there are no regressions that would break existing code.

Unlike Python typing it's not only type erasure: enums, namespaces, decorators, access modifiers, helper functions and so on need to be transformed into their JavaScript equivalent.



I'm not worried about that too much to be honest.

To me beyond v4.4 or so, when it started being possible to create crazy recursive dependent types (the syntax was there since ~4.1 - it's just that the compiler complained), there weren't a lot of groundbreaking new features being added, so unless an external library requires a specific TS version to parse its type declarations, it doesn't change much.



If Node.js can run TypeScript files directly, then the TypeScript compiler won't need to strip types and convert to JavaScript - it could be used solely as a type checker. This would be similar to the situation in Python, where type checkers check types and leave them intact, and the Python interpreter just ignores them.

It's interesting, though, that this approach in Python has led to several (4?) different popular type checkers, which AFAIK all use the same type hint syntax but apply different semantics. However for JavaScript, TypeScript seems to have become the one-and-only popular type checker.

In Python, I've even heard of people writing types in source code but never checking them, essentially using type hints as a more convenient syntax for comments. Support for ignoring types in Node.js would make that approach possible in JavaScript as well.



Flow (by Facebook) used to be fairly significant in the JavaScript several years ago, but right now it's somewhat clear that TypeScript has won rather handily.



Before that there was the closure compiler (Google) which had type annotations in comments. The annotation syntax in comments was a little clunky but overall that project was ahead of it's time. Now I believe even inside google that has been transpiled to typescript (or typescript is being transpiled to closure, I can't remember which - the point is that the typescript interface is what people are using for new code).



Closure was also interesting because it integrated type checking and minification, which made minification significantly more useful.

With normal Javascript and typescript, you can't minify property names, so `foo.bar.doSomethingVeryComplicated()` can only be turned into `a.bar.doSomethingVeryComplicated()`, not `a.b.c()`, like with Closure. This is because objects can be indexed by strings. Something like `foo.bar[function]()` is perfectly valid JS, where the value of `function` might come from the user.

A minifier can't guarantee that such expressions won't be used, so it cannot optimize property accesses. Because Closure was a type checker and a minifier at the same time, it could minify the properties declared as private, while leaving the public ones intact.



> it could minify the properties declared as private, while leaving the public ones intact.

I don't think it ever actually did this. It renamed all properties (you could use the index syntax to avoid this) and just used a global mapping to ensure that every source property name was consistently renamed (no matter what type it was on). I don't think type information was ever actually used in minification.

So if you had two independent types that had a `getName` function the compiler would always give them the same minified name even though in theory their names could be different because they were fully independent types. The mapping was always bijective. This is suboptimal because short names like `a` could only be used for a single source name, leading to higher entropy names overall. Additionally names from the JS runtime were globally excluded from renaming. So any `.length` property would never be renamed in case it was `[].length`.



Teaser has a mangle props feature. The mangle props by exact name or pattern has worked for me, but it might affect library objects or browser built-ins that I definitely don't want it to, but I've not gotten the mangle all props of this object version of it to work.



> A minifier can't guarantee that such expressions won't be used, so it cannot optimize property accesses.

Given TypeScript’s type system is unsound, neither could it even if it tried, right? I guess Flow could, but well, here we are.



Yeah, Flow had the ambition to be sound but has never accomplished it.

If you read the Flow codebase and its Git history, you can see that it's not for lack of trying, either — every couple of years there's an ambitious new engineer with a new plan for how to make it happen. But it's a real tough migration problem — it only works if they can provide a credible, appealing migration path to the other engineers across Facebook/Meta's giant JS codebase. Starting from a language like JS with all the dynamic tricks people use there, that's a tough job.

(And naturally it'd be even harder if they were trying to get any wider community to migrate, outside their own employer.)



What do you mean by unsound exactly.

I'm asking because there's no accepted definition of what an unsound type system is.

What I often see is that the word unsound is used to mean that a type system can accept types different to what has been declared, and in that case there's nothing unsound about ts since it won't allow you to do so.



> and in that case there's nothing unsound about ts since it won't allow you to do so

Consider this example (https://www.typescriptlang.org/play/?ssl=10&ssc=1&pln=1&pc=1...):

  function messUpTheArray(arr: Array): void {
      arr.push(3);
  }
  
  const strings: Array = ['foo', 'bar'];
  messUpTheArray(strings);
  
  const s: string = strings[2];
  console.log(s.toLowerCase())
  
Could you explain how this isn't the type system accepting types "different to what has been declared"? Kinda looks like TypeScript is happy to type check this, despite `s` being a `number` at runtime.


That's a good example, albeit quite of a far-fetched one.

In Haskell land, where the type system is considered sound you have `head` functions of type `List a -> a` that are unsound too, because the list might be empty.



That option also exists, you can just leave out the `messUpTheArray` lines and you get an error about how `undefined` also doesn't have a `.toLowerCase()` method.

However this problem as stated is slightly different and has to do with a failure of OOP/subtyping to actually intermingle with our expectations of covariance.

So to just use classic "animal metaphor" OOP, if you have an Animal class with Dog and Cat subclasses, and you create an IORef, a cell that can contain a cat, you would like to provide that to an IORef function because you want to think of the type as covariant: Cat is a subtype of Animal, F should be a subtype of F. The problem is that this function now has the blessing of the type system to store a Dog in the cell, which can be observed by the parts that still consider this an IORef.

Put slightly differently, in OOP, the methods of IORef all accept an implicit IORef called `this`, if those methods are part of what define an IORef then an IORef is necessarily invariant, not covariant, in . And then you can't assume subtyping. So to be sound a subtype system would presumably have to actually mark contra/covariance around everything, and TypeScript very intentionally documents that they don't do this and are just trying to make a "best effort" pass because JavaScript has 0 types, and crappy types are better than no types, and we can't wait for perfect types to replace the crappy types.



> In Haskell land, where the type system is considered sound you have `head` functions of type `List a -> a` that are unsound too, because the list might be empty.

Haskell's `head` not is not an example of the type system being unsound (I stress this point because we've been talking about type system soundness, not something-else-soundness).

From the view of the type system, `head` is perfectly sound: if the list is empty, the resulting value is ⊥ ("bottom"). And ⊥ is an inhabitant of every type. Therefore, `head` returning ⊥ when given an empty list is perfectly fine. When you force ⊥ (i.e. use it any way whatsoever), an exception is thrown. See https://wiki.haskell.org/Bottom

This is very much not the same thing (or remotely analogous) to what we have in my TypeScript example. There, the code fails at runtime when I attempt to call `toLowerCase`, yes; what's worse is the slightly different scenario where we succeed in calling something we shouldn't:

  class Person {
    name: string;
   
    constructor(name: string) {
      this.name = name;
    }
   
    kill() {
      console.log("Killing: " + this.name);
    }
  }
  
  class Murderer extends Person { }
  
  class Innocent extends Person { }
  
  function populatePeopleFromDatabase(people: Array): void {
      // imagine this came from a real SQL query
      people.push(new Innocent("Bob"));
  }
  
  
  function populateMurderersFromDatabase(people: Array): void {
      // TODO(Aleck): come back and replace this with a query that only selects murderers.
      //              i wanted to get the rest of the code in place, and this type checks,
      //              so I'll punt on this for now and come back later when I wrap my head
      //              around the proper SQL.
      //              we're not actually using this anywhere just yet, so no biggie ¯\_(ツ)_/¯
      populatePeopleFromDatabase(people);
  }
  
  // ... some time later, Bob comes along and implements the murderer execution logic:
  const murderers: Array = [];
  populateMurderersFromDatabase(murderers);
  // Bob is about to have a really shitty day:
  murderer.forEach((murderer) => murderer.kill());
It is not possible to write an analogous example in Haskell using `head`.


> there's no accepted definition of what an unsound type system is

Huh?

The cheeky answer would be that the definition here is the one the TypeScript documentation itself uses[1].

The useful answer is that there’s only one general definition that I’ve ever encountered: a type system is sound if no well-typed program encounters type errors during its execution. Importantly, that’s not a statement about the (static) type system in isolation: it’s tied to the language’s dynamic semantics.

The tricky part, of course, is defining “type error”. In theoretical contexts, it’s common to just not define any evaluation rules at all for outwardly ill-typed things (negating a list, say), thus the common phrasing that no well-typed program must get stuck (unable to evaluate further). In practical statically-typed languages, there are on occasion cases that are defined not to be type errors essentially by fiat, such as null pointer accesses in Java, or escape hatches, such as unsafeCoerce in practical implementations of Haskell.

Of course, ECMAScript just defines behaviour for everything (except violating invariants in proxy handlers, in which case, lol, good luck), so arguably every static type system for it is sound, even one that allows var foo: string = 42. Obviously that’s not a helpful point of view. I think it’s reasonable to say that whatever we count as erroneous situations must at the very least include all occurrences of ReferenceError and TypeError.

TypeScript prevents most of them, which is good enough for its linting use case, when the worst possible result is that a buggy program crashes. It would definitely not be good enough for Closure Compiler’s minification use case, when the worst possible result is that a correct program gets silently miscompiled (misminified?).

[1] https://www.typescriptlang.org/docs/handbook/type-compatibil...



It's maybe useful to note in this discussion for some that "soundness" of a type system is a bit of technical/theoretical jargon that in some cases has specific mathematical definitions and so "unsound" often sounds harsher (connotatively) than it means. The vast majority of type systems are "unsound" for very pragmatic reasons. Developers don't often care to work in a "sound" type systems. Some of the "most sound" type systems we've collectively managed to build are in things like theorem provers and type assertion systems that some of us don't always even consider useful for "real" software development.

Typescript is a bit more unsound than most because of the escape hatch `any` and because of the (intentional) disconnect between compiler and runtime environment. Even though "unsound" sounds like a bad thing to be, it's a big part of why Typescript is so successful.



There's nothing arcane or particularly theoretical about soundness. It means that if you have an expression of some type, and at runtime the expression evaluates to a value, the value will always be of that type.

For example if you have a Java expression of type MyClass, and it gets evaluated, then it must either throw (so that it doesn't produce any value) or produce a value of type MyClass: either an instance of MyClass, or of one of its subclasses, or null. It will never produce an instance of some other class, or an int, or anything else that isn't a valid value for the type MyClass.

In addition to helping human readers reason about the code, a sound type system is a big deal for a compiler: it makes it possible to compile the code AOT to fast native code, without inserting a bunch of runtime checks and dynamic dispatching to handle the fact that inevitably some of the types (but you don't know which) are wrong.

The compiler implications are what motivated the Dart language's developers to migrate from an unsound to a sound type system a few years ago: https://dart.dev/language/type-system#the-benefits-of-soundn... so that they could compile Flutter apps AOT. This didn't require anyone to make their code resemble what you'd do in a theorem prover — it just means that, for example, all casts are checked, so that they throw if the value doesn't turn out to have the type the cast wants to return.

TypeScript is unsound because when you have an expression with a type, that tells you nothing at all for sure about what the value of the expression can be — it might be of that type, or it might be anything else. It's still valuable because you can maintain a codebase where the types are mostly accurate, and that's enough to help a lot in reading and maintaining the code.



The key factor is that typescript is not a language, it is a notation system for a completely independent language.

The purpose of typescript is usefully type as much javascript as possible, to do both this and have a sound type system it would require to change javascript.



Definitely to get the most ergonomic programming experience, while also having a sound type system, you'd need to change some of the semantics of the language.

A prime example is that if you index into an array of type `T[]`, JS semantics mean the value you get back could be undefined as well as a `T`. So to describe existing JS semantics in a sound type system, the type would have to be `T | undefined`, which would be a big pain. Alternatively you could make the type `T` and have that be sound, but only if you make the runtime semantics be that an out-of-bounds access throws instead of returning undefined.



That's true but misleading: if "any" and "unknown" were the only types, then "any" would be indistinguishable from "unknown" and you'd really have just the one type. Which makes the type system sound because it doesn't say anything.

If your type system has at least two types that aren't the same as each other, then adding "any" makes it unsound right there. The essence of "any" is that it lets you take a value of one type and pretend it's of any other type. Which is to say that "any" is basically the purified form of unsoundness.



Theoretically TS could… until it encounters an ‘any’ type in that code path, then it would have to give up.

But there are TSconfig options to ensure no use of any so with the right level of strictness it could happen.



of course this created an interoperability nightmare with third party libraries, which irrevocably forked Google's whole JS ecosystem from the community's 20 years ago and turned their codebases into a miserable backwater.



Closure is almost a forgotten child of Google now. Does not even fully support ES2022 as of today. We are working hard to get rid of it completely. Surprise, lots of important projects still rely on it today.



Oh, Closure Compiler is such a throwback. I still remember staring at the project page on Google Code. Isn't it like two decades old or even older by this point? Is it still alive?



This can give you some hints of the current status of closure compiler:

https://github.com/google/closure-compiler/issues/2731

I happen to know this because we have some old projects that depend on this and are working hard to get rid of the dependency.

I wish Google either updates it or just mark the whole thing deprecated -- the world has already moved on anyway. Relating this to Google's recent cost cutting, and seeing some other Google's open source projects more or less getting abandoned, I have to say that today's Google is definitely not the same company from two decades ago.



There was no real competition, Flow was a practical internal tool with 0 marketing budget. Typescript is typical MS 3E strategy with a huge budget. Needless to say, Flow is much more practical and less intrusive, but marketing budget captured all the newbie devs.



Have to disagree. I tried Flow irrespective of marketing and didn’t think it was polished. Kept running into type situations that the language didn’t support well. Kept bugging out in my IDE. Had no elegance.



When I last used it, as a type system it was much better than TypeScript. A lot of flow features now exist in TypeScript though too.

One big annoyance with Flow I had is like you said: unpolished tooling. Another was frequent breaking changes (I don't hold it against them too much, it was 0.x software after all)

Also because features diverged, you had to maintain type defs for multiple versions of flow for for multiple library versions. And then at one point, they also decided to convert internal errors to any types instead of displaying the error. That was the last straw for me, especially since I maintained a few flow type defs. I spent _so_ much of _my_ time just on type def maintenance for open source libraries already, with the any decay I like flying blind too. So I just switched to TS with its interior type system: it was good enough and others maintained library typedefs for me. But now the type systems are much more closely aligned (unless flow drifted), so switching to TS paid off in the end.



TypeScript was really really easy to get started with back in the day. It allows for incremental correctness, has good docs, and good tooling. On top of that a lot of beginner React tutorials started out with TypeScript, which onboarded a lot of new engineers to the TS ecosystem, and got them used to the niceties of TS (e.g. import syntax).



I don’t know what axe you have to grind, but TypeScript is firmly in the hands of the community now. There’s not much Microsoft could do to change that. In what way would it be rent-seeking?



Flow tries to be sound and that makes it infinitely better than TS where the creators openly threw the idea of soundness out the window from the very beginning.



This is a point in Flow's favour. However! Seven years ago or so, when TypeScript was quite young and seemed inferior to Flow in almost all respects, I chose Flow for a large project. Since then, I spent inordinate amounts of time updating our code for the latest breaking Flow version, until one came along that would have taken too long to update for, so we just stayed on that one. We migrated to TypeScript a little while back and the practical effect has been much more and effective type checking through more coverage and support. TypeScript may be unsound, but it works better over all. We turn on the vast majority of the safety features to mitigate the unsoundness. And it's developed by a team that are beholden to a large and vibrant user base, so any changes are generally well-managed. There's no contest, really.



I know, but it just doesn't matter enough. Believe me, I'm signed up to the idea of theoretical rigour, the argument for soundness is part of what originally won me over (along with previous good experiences with Flow on a smaller project, and the support for gradual adoption). I will continue to be drawn to languages and tools that have a strong theoretical foundation. But in this particular case, today, when comparing these particular projects in the large JavaScript codebase I am talking about, TypeScript still wins by some distance. I promise that it has caught way more errors and been more generally helpful in its language server abilities than Flow ever was. Maybe Flow has caught up since then in its core functionality, I haven't been keeping track, but there would still be the wide disparity in community support which has serious implications for developer education, availability of library type definitions, etc.



I think the real answer is adding actually sound types to JS itself.

One of the biggest revolutions in JS JITS was the inline cache (IC). It allows fast lookup and specialized functions (which would be too expensive otherwise). These in turn allow all the optimizations of the higher-tier JITs.

The biggest problem of Flow and TS is encouraging you to make slow code. The second you add a generic to your function, you are undoubtedly agreeing that it is going to be accepting more than 4 types. This means your function is megamorphic. No more IC. No more optimization (even worse, if it takes some time to hit those 5+ types, you get the dreaded deoptimization). In theory, they could detect that your function has 80 possible variations and create specialized, monomorphic functions for each one, but that's way too much code to send over the wire. That kind of specialization MUST be done in the JIT.

If you bake the types into the language via a `"use type"` directive, this give a LOT of potential. First, you can add an actually sound type system. Second, like `"use strict"` eliminated a lot of the really bad parts of JS, you can eliminate unwanted type coercion and prevent the really dynamic things that prevent optimization. Because the JIT can use these types, it can eliminate the need for IC altogether in typed functions. It can still detect the most-used type variants of a function and make specialized versions then use the types to directly link those call sites to the fast version for even more optimization.

I use TS because of its ubiquity, but I think there's the possibility for a future where a system a little more like Flow gets baked into the language.



TS made the choice to be “just JS” + types, and lean into JS-isms.

Both choices are reasonable ones to make. Flow has some really cool stuff, and works great for a lot of people.

There’s no denying, though, that there’s TS has done something right (even if you personally dislike it)



When a language's type system is sound, that means that if you have an expression with type "string", then when you run the program the expression's value will only ever be a string and never some other sort of value.

Or stated more abstractly: if an expression has type T, and at runtime the expression evaluates to a value v, then v has type T.

The language can still have runtime errors, like if you try to access an array out of bounds. The key is that such operations have to give an error — like by throwing, so that the expression doesn't evaluate to any value at all — rather than returning a value that doesn't fit the type.

Both TypeScript and Flow are unsound, because an expression with type "string" can always turn out to evaluate to null or a number or an object or anything else. Flow had the ambition to be sound, which is honorable but they never accomplished it. TypeScript announced up front that they didn't care about soundness: https://www.typescriptlang.org/docs/handbook/type-compatibil...

Soundness is valuable because it makes it possible to look at the types and reason about the program using them. An unsound type-checker like TypeScript or Flow can still be very useful to human readers if most of the types in a codebase are accurate, but you always have to keep that asterisk in the back of your head.

One very concrete consequence of soundness that it makes it possible to compile the code to fast native code. That's what motivated Dart a few years ago to migrate from an unsound type system to a sound one: https://dart.dev/language/type-system so that it could AOT-compile Flutter apps for speed.



You don't have to go even that far to find unsoundness in flow.
    const arr = ["abcd"];
    const str = arr[1];
    const num = str.length; // this throws
    console.log(num);
For me, typescript is a pretty good balance.


I think this is not a very good example. Not only does it also throw in TS, but it even throws in Haskell which is pretty much the poster boy for sound type systems.

This isn't a type error unless your type system is also encoding lengths, but most type systems aren't going to do that and leave it to the runtime (I suspect the halting problem makes a general solution impossible).

    main = putStrLn (["a", "b", "c"]!!4)


Yes it throws in typescript. Typescript isn't the the language chasing soundness at any cost. This just illustrates the futility of chasing soundness.

Soundness is good as long as the type-checking benefit is worth the cost of the constraints in the language. If the poster child for soundness isn't able to account for this very simple and common scenario, then nothing will actually be able to deliever full soundness.

It's just a question of how far down the spectrum you're willing to go. Pure js is too unsound for my taste. Haskell is too constrained for my taste. You might come to a different conclusion, but for me, typescript is a good balance.



My balance point is StandardML. SML and TS both have structural typing (I believe this is why people find TS to be more ergonomic). SML has an actually sound type system (I believe there is an unsoundness related to assigning a function to a ref, but I've never even seen someone attempt to do that), but allows mutation, isn't lazy, and allows side effects.

Put another way, SML is all the best parts of TS, but with more soundness and none of the worst parts of TS and non of the many TS edge cases baked into the language because they keep squashing symptoms of unsoundness or adding weird JS edge cases that you shouldn't be doing anyway.



I have no idea about the lawyerly technicalities, but you can try it yourself to verify what I'm saying.

https://flow.org/try/

Compare these two programs.

    const arr = ["abcd"];
    const str = arr[1];
    const num = str.length; // this throws at runtime

    const arr = [new Date];
    const dt = arr[1];
    const num = dt.length; // fails to type check


This is different. Neither flow, typescript, nor javascript generate a runtime error for an out of bounds index. It's explicitly allowed by the language.

The result of the an OOB access of an array is specified to be `undefined`. The throw only happens later when the value is treated as the wrong type.

I don't consider a runtime error to be a failure of the type system for OOB array access. But in javascript, it's explicitly allowed by specification. It's a failure of any type system that fails to account for this specified behavior in the language.



> It's explicitly allowed by the language.

This is like arguing that a null exception is fine because it's allowed by the language. If you get `undefined` when you expect another type, most future interaction are guaranteed to have JS throw because of the JS equivalent of a null pointer exception. They are technically different because a dynamic language runtime can prevent a total crash, but the effect on your web app is going to be essentially the same.

    [1,2,3][4].toFixed(2)
> It's a failure of any type system that fails to account for this specified behavior in the language.

Haskell has the ability to handle the error.

How do you recommend a compiler to detect out-of-bounds at compile time? It can certainly do this for our trivial example, but that example will also be immediately evident the first time you run the code too, so it's probably not worth the effort. What about the infinite number of more subtle variants?



> How do you recommend a compiler to detect out-of-bounds at compile time?

I wouldn't make the recommendation that they do at all. Full soundness is not my thing. But... if Flow wanted to do it, it would have to change the type of indexing into `(Element[])[number]` with a read from `Element` to `Element | undefined`.



> In Python, I've even heard of people writing types in source code but never checking them, essentially using type hints as a more convenient syntax for comments.

Note that there's IDEs that'll use type hints to improve autocomplete and the like too, so even when not checking types it can make sense to add them in some places.



You can have this now adding types with JSDoc and validating them with typescript without compiling, you get faster builds and code that works everywhere without magic or need to strip anything else than comments.

The biggest pain point of using JSDoc at least for me was the import syntax, this has changed since Typescript 5.5, and it's now not an issue anymore.



For god's sake, please stop shilling JSDoc as a TS replacement. It is not. If you encounter anything more complicated than `A extends B`, JSDoc is a pain in the ass of huge intensity to write and maintain.



I’ve had a lot of success combining JSDoc JS with .d.ts files. It’s kind of a Frankenstein philosophically (one half using TS and one half not) but the actual experience is great: still a very robust type system but no transpiling required.

In a world where ES modules are natively supported everywhere it’s a joy to have a project “just work” with zero build steps. It’s not worth it in a large project where you’re already using five other plugins in your build script anyway but for small projects it’s a breath of fresh air.



I do this as well. JSDoc is great for simple definitions, but as soon as you want to do something more complicated (generics, operators, access types, etc) you get stuck. The .d.ts are ignored because you're only importing them within JSDoc comments.



You should write complex types in interfaces files where they belong, and there's full typescript support.

I use this approach professionally in teams with many developers, and it works better for us than native TS. Honestly give it a try, I was skeptical at first.



In general JSDoc is just much more verbose and has more friction, even outside complex types. I recently finished a small (20 files/3000 lines), strictly typed JS project using full JSDoc, and I really miss the experience of using the real TypeScript syntax. Pain points: annotating function parameter types (especially anonymous function), intermediate variable type and automatic type-only import, these are the ones that I can remember. Yes you can get 99% there with JSDoc and .d.ts files, but that's painful.



I use snippets to write those, yes it's more verbose there's not denying that.

For me the advantages of just having JS files and not worrying about more complex source-maps, build files, etc definitely makes it worth it.



Source maps and build files are automatically generated when bundling which you need to do with or without typescript… so this argument always confuses me. There is no tangible downside in my experience.. either way it’s just typing “pnpm build”.



You can configure tsconfig.json to read JSDoc and error on invalid types so that you effectively get the same behavior as writing typescript.



You can’t write complex TypeScript types in JSDoc, which is what GP said.

The moment you need to declare or extend a type you’re done, you have to do so in a separate .ts file. It would be possible to do so and import it in JSDoc, but as mentioned before it’s a huge PITA on top of the PITA that writing types can already be (e.g. function/callbacks/generics)



JSDoc absolutely does not scale and allows for very limited type programming.

It's fine on toy projects, and somewhat I would say, for 99% of users that don't even know what a mapped or intersection type is.



JSDoc does not scale, but some projects are just better when they aren't scaled.

JSDoc is indeed fine on toy project, or in fact any project (even prod-ready ones) that doesn't warrant the trouble of adding NPM packages and transpilation steps.

Although they are rare, those type of small, feature-complete codebases do exists.



> If Node.js can run TypeScript files directly, then the TypeScript compiler won't need to strip types and convert to JavaScript

Node.JS isn't the only JS runtime. You'll still have to compile TS to JS for browsers until all the browsers can run TS directly. Although some bundlers already do that by using a non-official compiler, like SWC (the one Node's trying out for this feature).

> In Python, I've even heard of people writing types in source code but never checking them, essentially using type hints as a more convenient syntax for comments.

It's not just comments. It's also, like the name "type hint" suggests, a hint for your IDE to display better autocomplete options.



Specifically, I saw JSDoc syntax and it triggered me so much that I closed the page and threw my phone away in disgust at absurdness of even the idea that someone thought having something like this unironically is a remotely good idea.



What do you mean ugly? This basically is making Typescript official.

They just can't have browsers doing the actual type checking because there isn't a specification for how to do that, and writing one would be extremely complicated, and I'm not sure what the point would be anyway.



Incidentally, this is how the ecmascript proposal for introducing types to JS would work by default. The runtime would ignore the types when running code. If you want type checking, you’d have to reach for external tooling.



> In Python, I've even heard of people writing types in source code but never checking them

This is my main approach. Type hints are wonderful for keeping code legible/sane without going into full static type enforcement which can become cumbersome for rapid development.



You can configure typescript to make typing optional. With that option set, you can literally rename .js files to .ts and everything "compiles" and just works. Adding this feature to nodejs means you don't even have to set up tsc if you don't want to.

But if I were putting in type hints like this, I'd still definitely want them to be statically checked. Its better to have no types at all than wrong types.



> Its better to have no types at all than wrong types.

I agree - but the type systems of both Python and TypeScript are unsound, so all type hints can potentially be wrong. That's one reason why I still mostly use untyped Python - I don't think it's worth the effort of writing type annotations if they're just going to sit there and tell lies.

Or maybe the unsoundness is just a theoretical issue - are incorrect type hints much of a problem in practice?



Is this “unsound”-ness that you’re referring to because it uses structural typing and not nominal typing?

Fwiw I’ve been working with TypeScript for 8+ years now and I’m pretty sure wrong type hints has never been a problem. TS is a God-send for working with a codebase.



No, TypeScript is not unsound because it uses structural typing.

A language has a sound type system if every well-typed program behaves as defined by the language's semantics during execution.

Go is structurally typed, and yet it is sound: code that successfully type checks is guaranteed to abide the semantics of the language.

TypeScript is unsound because code that type checks does not necessarily abide the semantics of the language:

  function messUpTheArray(arr: Array): void {
      arr.push(3);
  }
  
  const strings: Array = ['foo', 'bar'];
  messUpTheArray(strings);
  
  const s: string = strings[2];
  console.log(s.toLowerCase())
`strings` is declared as a `Array`, but TypeScript is happy to insert a `number` into it. This is a contradiction, and an example of unsoundness.

`s` is declared as `string`, but TypeScript is happy to assign a `number` to it. This is a contradiction, and an example of unsoundness.

This code eventually fails at runtime when we try to call `s.toLowerCase()`, as `number` has no such function.

What we're seeing here is that TypeScript will readily accept programs which violate its own rules. Any language that does this, whether nominally typed or structurally typed, is unsound.



There's not much connection. Typescript's record types aren't sound, but that's far from its only source of unsoundness, and sound structural typing is perfectly possible.



Soundness is also a highly theoretical issue that I've never once heard a professional TypeScript developer express concern about and have never once heard a single anecdote of it being an issue in real-world code that wasn't specifically designed to show the unsoundness. It usually only comes up among PL people (who I count myself among) who are extremely into the theory but not regularly coding in the language.

Do you have an anecdote (just one!) of a case where TypeScript's lack of type system soundness bit you on a real application? Or an anecdote you can link to from someone else?



> Do you have an anecdote (just one!) of a case where TypeScript's lack of type system soundness bit you on a real application?

Sure. The usual Java-style variance nonsense is probably the most common source, but I see you're not bothered by that, so the next worst thing is likely object spreading. Here's an anonymized version of something that cropped up in code review earlier this week:

    const incomingValue: { name: string, updatedAt: number } = { name: "foo", updatedAt: 0 }

    const intermediateValueWithPoorlyChosenSignature: { name: string } = incomingValue

    const outgoingValue: { name: string, updatedAt: string } = { updatedAt: new Date().toISOString() , ...intermediateValueWithPoorlyChosenSignature }


I mean... yes, there's a footgun there where you have to know to spread first and then add the new properties. That's just a good practice in the general case: an intermediate type that fully described the data wouldn't have saved you from overwriting it unless you actually looked closely at the type signature.

And yes, TypeScript types are "at least these properties" and not "exactly these properties". That is by design and is frankly one reason why I like TypeScript over Java/C#/Kotlin.

I'd be very interested to know what you'd do to change the type system here to catch this. Are you proposing that types be exact bounds rather than lower bounds on what an object contains?



> That's just a good practice in the general case: an intermediate type that fully described the data wouldn't have saved you from overwriting it unless you actually looked closely at the type signature.

The issue isn't that it got overridden, it's that it got overridden with a value of the wrong type. An intermediate type signature with `updatedAt` as a key will produce a type error regardless of the type of the corresponding value.

> I'd be very interested to know what you'd do to change the type system here to catch this.

Like the other commenter said, extensible records. Ideally extensible row types, with records, unions, heterogeneous lists, and so on as interpretations, but that seems very unlikely.



Look into "Row types" and how PureScript, Haskell, and Elm (to a limited extent) do it.

'{foo :: Int | bar} is a record with a known property 'foo' and some unspecified properties 'bar'. You cannot pass a `{foo :: Int, bar :: Int}` into a function that expects `{foo :: Int}`.

A function that accepts any record with a field foo, changes foo, keeping other properties intact has the type

    {foo :: Int | bar} -> {foo :: Int | bar}


Ah someone else posted a link and I understand the unsoundness now.

The only time an issue ever came up for me was in dealing with arrays

  let foo: number[] = [0, 1, 2]

  // typed as number but it’s really undefined
  let bar = foo[3]
But once you’re aware of the caveat it’s something you can deal with, and it certainly doesn’t negate the many massive benefits that TS confers over vanilla JS.


Yeah, that example is unsound in the same way that Java's type system is unsound, it's a compromise nearly all languages make to avoid forcing you to add checks when you know what you're doing. That's not the kind of problem that people usually are referring to when they single out TypeScript.



I've been using TypeScript professionally for 6+ years and have only ever run into issues at the border between TypeScript and other systems (usually network, sometimes libraries that don't come with types). There are a few edge cases that I'm aware of, but they don't really come up in practice.



Or you can configure the TS compiler to allow JS imports, then everything also compiles and works, but you can slowly convert your codebase from JS to TS file by file and be sure that all TS files are properly typed and all JS files are untyped instead of having everything as TS files where some are typed and some are not.



Yeah I start projects by explicitly typing `any` all over the place and gradually refining things, so every type that's specified is explicit and checked, I'm really enjoying that style.



Combine this with an eslint config that nudges you about explicit any, and the typescript compiler option to disallow implicit any, and you're well taken care of.



With this approach, do you still use Python's standard syntax for type hints?
    def mersenne(p: int): return 2**p - 1
Or, given there's no need for the type hints to be checker-friendly, do you make them more human-friendly, e.g:
    def mersenne(p: 'prime number'): return 2**p - 1


While it’s not common (from the source code I’ve reviewed over the years), some people make a new type with a name and use that in the definition:

```

from typing import NewType

# Create a new type for some_prime SomePrime = NewType('SomePrime', int)

def process_prime(value: SomePrime) -> int: return value ```

However, this isn’t nearly as common as simply using a more descriptive argument name like “prime_number : int”

One of the big advantages to type hinting in Python is that it feeds the IDE a lot of information to increase auto-complete functionality, so you want to avoid things like p:”prime number”



Exactly. And if you use a library that does lots of meta programming (like Django) then it's impossible to pass all type errors. Hopefully one day the type system will be powerful enough to write a Django project with passing tests.



I don't find TypeScript to be burdensome when rapidly iterating. Depending on how you've configured your dev environment you can just ignore type errors and still run the code.



If this feature ever becomes the default (ie not behind a flag) - how will the NPM ecosystem respond? Will contributors still bother to build CJS end EJS versions when publishing a NPM module, or just slap an 'engine: nodejs >= 25' on the package.json and stop bothering with the build step before pushing to NPM ?

I personally would very much prefer if NPM modules that have their original code in TS and are currently transpiling would stop shipping dist/.cjs so I unambiguously know where to put my debugger/console.log statements. And it would probably be very tempting to NPM contributors to not have to bother with a build step anymore.

But won't this start a ripple effect through NPM where everyone will start to assume very quickly 'everyone accepts TS files' - it only takes one of your dependencies for this effect to ripple through? It seems to me that nodejs can't move this outside an opt-in-experimental-flag without the whole community implicitly expecting all consumers to accept TS files before you know it. And if they do, it will be just months before Firefox and Safari will be force to accept it too, so all JS compilers will have to discard TS type annotations

Which I would personally be happy with - we're building transcompiling steps into NPM modules that convert the ts code into js and d.ts just to support some hypothetical JS user even though we're using TS on the including side. But if node accepts .ts files we could just remove those transpiling steps without ever noticing it... so what's stopping NPM publishers from publishing js/d.ts files without noticing they broke anything?



The legendary Ryan dahl is actually working on solving the exact problem you described by creating a new package registry called JSR.

Essentially what it does is allow you to upload your typescript code without a build step so when other devs install it they can see the source code of the module in it's original typescript instead of transpiled JavaScript.



That's really cool. One of the benefits of the JS ecosystem is the ability to step through code and crack open your dependencies. Not sure if this would directly make this possible when running your projects/tests, but it at least sounds like a step in that direction.



I just checked and you are correct, in node it only installs the compiled version.

Apparently you can only view the uncompiled source code in deno since it natively supports typescript.

My bad



I would love to ship my source code (.ts) to npm. But Typescript team was very much against this, as there'll be tsconfig issues and other performance issues. But still fingers crossed.



For the old libraries I maintain that are typescript and transpiled into .cjs and .mjs for npm, I'll probably just start shipping all three versions.

For a new thing I was writing from scratch, yeah, I might just ship typescript and not bother transpiling.

[edit: Apparently not. TS is only for top-level things, not libraries in node_modules according to the sibling comment from satanacchio who I believe is the author of the PR that added TS support and a member of the Node.js Technical Steering Committee]



Because I don't like breaking things unnecessarily. Some of my libraries are 10 years old and depended upon by similarly old projects that are not using ESM and probably never will.

Besides, it's already going through one transpilation step to go from TS to ESM, so adding a second one for CJS really isn't that much hassle.

I think if node.js had made require() work with ESM, I could probably drop CJS. But since that's probably never going to happen, I'm just going to continue shipping both versions for old projects and not worry about it.



> adding a second one for CJS

Nobody is arguing for that. Once you ship ESM, you can continue shipping ESM.

In Node 22 you can even require() ES modules (with an experimental flag, at the moment)



> > adding a second one for CJS

> Nobody is arguing for that. Once you ship ESM, you can continue shipping ESM.

I'm not sure I follow you there. I did continue shipping ESM.

> In Node 22 you can even require() ES modules (with an experimental flag, at the moment)

Oh, I didn't know about that, cool! Once it becomes un-flagged I might consider dropping CJS.



> I think if node.js had made require() work with ESM, I could probably drop CJS

Why is making downstream have to switch to `await import()` that big of a deal?

You can use async/await in CJS just fine. Sure, sometimes you may need to resort to some ugly async IIFE wrappers because CJS doesn't support top-level await like ESM does, but is that really such a big deal?

Sure, it's a breaking change, but that's what semver major bumps are for.

I just think that if projects want to stay in CJS they should learn how to use async/await. I clearly don't understand why CJS libraries feel a need synchronous require() for everything. (Though to be fair, I've also never intentionally written anything directly in CJS. I learned enough in the AMD days to avoid CJS like a plague.)



> Why is making downstream have to switch to `await import()` that big of a deal?

> You can use async/await in CJS just fine. Sure, sometimes you may need to resort to some ugly async IIFE wrappers because CJS doesn't support top-level await like ESM does, but is that really such a big deal?

It might seem like a small amount of work, but for a library one must to multiply that small amount of work by the number of users who will have to repeat it. It can be a quite large amount in aggregate. And, for what benefit? So I can drop one line from my CI config? It just seems like a huge waste of everyone's time.

Also, as a library user, I would (and occasionally do) get annoyed by seemingly unnecessary work foisted on my by a library author. It makes me consider whether or not I want to actually depend on that library, and sometimes the answer is no.



> multiply that small amount of work by the number of users who will have to repeat it

This is probably where we have the biggest difference in our calculations. I know there's a lot of pain in legacy CJS systems, but from my view (which is maybe more "browser-oriented", which is maybe a bit more Deno/Bun-influences, which comes from a "Typescript-first" mentality going way back to 0.x) it is more legacy "giant balls of mud" maintained by a sparse few developers. I don't see this multiplicand as very big on the scale of library user count. Most CJS for years and years has been transpiled from Typescript or Rollup; most CJS only exists to be eaten by Webpack or other bundler, many of which today rewrite CJS to ESM anyway. From what I see a lot of CJS seems either transpiled out of habit (for some notion of supporting Node < 10 that doesn't make sense with current security support) or by accident (by a misconfigured tsconfig.json, for example, and then often looping back through a transpiler again back to ESM). The way we cut through the Gordian knot of we're all doing too much transpilation to/from CJS is to start eliminating automated transpilation to CJS in the first place. Which is why I find it useful every time to ask people what they are really trying to do when transpiling to .cjs today.

Of course, if your multiplicand is lines-of-code impacted, because I agree there are some great big huge piles of mud in CJS that are likely stuck that way for lack of developers/maintainers and lack of time/budget/money, then worrying about the minority of users still intentionally using CJS is worth caring about, and my sympathies in that situation.

You, of course, know your library's users better than me and maybe you do have a lot of CJS users that I just wouldn't consider in my calculations. I'm not going to stop you from transpiling to CJS if you find that necessary for your library. That's your judgment call. I just wanted to also make sure to ask the questions of "do you really need to?" and "how many users do you actually think it will impact?" out loud. Thanks a lot for the conversation on it, and I'm still going to be a radical banging the "CJS Must Die" drum, but I understand pragmatism and maintenance needs, especially those of legacy applications, and mostly just want to make sure the conversation is an active one and a lot less of passively transpiling stuff that doesn't really need it.



Aight let me chime in here.

I'm a game developer. I make web games. We run our games with a simple nginx server that simply serves the wasm. We have some JavaScript libraries we use. They have to be raw dog .js.

I don't even know what your "ejs" or "cjs" acronyms mean.

We use the discord JavaScript SDK. Discord only ships it as a node module or as .ts.

It's a pain in our ass to update because we don't know what those tools your talking about are and we don't want to know. Just give me the damn .js



I'm on your side. No one should have to care about the difference between ESM (.mjs) and CJS (.cjs). CJS should just be dead and we only need one .js again. If you are following the Discord JS docs and using modern JS syntax `import` and `export` statements (if you are "raw dogging" it, have you heard the good word of