NHacker Next
  • new
  • past
  • show
  • ask
  • show
  • jobs
  • submit
Experiment: Making TypeScript immutable-by-default (evanhahn.com)
jbreckmckye 8 hours ago [-]
> If you figure out how to do this completely, please contact me—I must know!

I think you want to use a TypeScript compiler extension / ts-patch

This is a bit difficult as it's not very well documented, but take a look at the examples in https://github.com/nonara/ts-patch

Essentially, you add a preprocessing stage to the compiler that can either enforce rules or alter the code

It could quietly transform all object like types into having read-only semantics. This would then make any mutation error out, with a message like you were attempting to violate field properties.

You would need to decide what to do about Proxies though. Maybe you just tolerate that as an escape hatch (like eval or calling plain JS)

Could be a fun project!

Cthulhu_ 7 hours ago [-]
One "solution" is to use Object.freeze(), although I think this just makes any mutations fail silently, whereas the objective with this is to make it explicit and a type error.
giancarlostoro 3 hours ago [-]
I used to have code somewhere that would recursively call Object.freeze on a given object and all its children, till it couldn't "freeze" anymore.
dunham 6 hours ago [-]
I thought Object.freeze threw an exception on mutation. Digging a little more, it looks like we're both right. Per MDN, it throws if it is in "use strict" mode and silently ignores the mutation otherwise.
zelphirkalt 51 minutes ago [-]
Isn't the idea to get a compile time error, rather than a runtime exception?
drob518 7 hours ago [-]
It’s interesting to watch other languages discover the benefits of immutability. Once you’ve worked in an environment where it’s the norm, it’s difficult to move back. I’d note that Clojure delivered default immutability in 2009 and it’s one of the keys to its programming model.
bastawhiz 7 hours ago [-]
I don't think the benefits of immutability haven't been discovered in js. Immutable.js has existed for over a decade, and JavaScript itself has built in immutability features (seal, freeze). This is an effort to make vanilla Typescript have default immutable properties at compile time.
iLemming 6 hours ago [-]
Javascript DOES NOT in fact have built-in immutability similar to Clojure's immutable structures - those are shallow, runtime-enforced restrictions, while Clojure immutable structures provide deep, structural immutability. They are based on structural sharing and are very memory/performance efficient.

Default immutability in Clojure is pretty big deal idea. Rich Hickey spent around two years designing the language around them. They are not superficial runtime restrictions but are an essential part of the language's data model.

hombre_fatal 6 hours ago [-]
Sure, though Immutability.js did have persistent data structures like Clojure.
iLemming 6 hours ago [-]
yeah, immutability.js is a solid engineering effort to retrofit immutability onto a mutable-first language. It works, but: it's never as ergonomic as language-native immutability and it just feels like you're swimming upstream against JS defaults. It's nowhere near Clojure's elegance. Clojure ecosystem assumes immutability everywhere and has more mature patterns built around it.

In Clojure, it just feels natural. In js - it feels like extra work. But for sure, if I'm not allowed to write in Clojurescript, immutability.js is a good compromise.

hombre_fatal 59 minutes ago [-]
I meant to point out that of course there is value in immutability beyond shared datastructures.

I tried Immutability.js back in the day and hated it like any bolted-on solution.

Especially before Typescript, what happened is that you'd accidentally assign foo.bar = 42 when you should have set foo.set('bar', 42) and cause annoying bugs since it didn't update anything. You could never just use normal JS operations.

Really more trouble than it was worth.

And my issue with Clojure after using it five years is the immense amount of work it took to understand code without static typing. I remember following code with pencil and paper to figure out wtf was happening. And doing a bunch of research to see if it was intentional that, e.g. a user map might not have a :username key/val. Like does that represent a user in a certain state or is that a bug? Rinse and repeat.

iLemming 12 minutes ago [-]
> immense amount of work it took to understand code without static typing.

I've used it almost a decade - only felt that way briefly at the start. Idiomatic Clojure data passing is straightforward once you internalize the patterns. Data is transparent - a map is just a map - you can inspect it instantly, in place - no hidden state, no wrapping it in objects. When need some rigidity - Spec/Malli are great. A missing key in a map is such a rare problem for me, honestly, I think it's a design problem, you cannot blame dynamically-typed lang for it, and Clojure is dynamic for many good reasons. The language by default doesn't enforce rigor, so you must impose it yourself, and when you don't, you may get confused, but that's not the language flaw - it's the trade-off of dynamic typing. On the other hand, when I want to express something like "function must accept only prime numbers", I can't even do that in statically typed language without plucking my eyebrow. Static typing solves some problems but creates others. Dynamic typing eschews compile-time guarantees but grants you enormous runtime flexibility - trade-offs.

the_gipsy 4 hours ago [-]
It doesn't make sense to say that. Other languages had it from the start, and it has been a success. Immutable.js is 10% as good as built-in immutability and 90% as painful. Seal/freeze,readonly, are tiny local fixes that again are good, but nothing like "default" immutability.

It's too late and you can't dismiss it as "been tried and didn't get traction".

agos 6 hours ago [-]
one thing that it's missing in JS to fully harness the benefits of immutability is some kind of equality semantics where two identical objects are treated the same
no_wizard 6 hours ago [-]
They were going to do this with Records and Tuples but that got scrapped for reasons I’m not entirely clear on.

It appears a small proposal along these lines has appeared in then wake of that called Composites[0]. It’s a less ambitious version certainly.

[0]: https://github.com/tc39/proposal-composites

lhnz 5 hours ago [-]
Records and Tuples were scrapped, but as this is JavaScript, there is a user-land implementation available here: https://github.com/seanmorris/libtuple
iLemming 7 hours ago [-]
Also, interestingly Clojurescript compiler in many cases emits safer js code despite being dynamically typed. Typescript removes all the type info from emmitted js, while Clojure retains strong typing guarantees in compiled code.
hden 7 hours ago [-]
Mutability is overrated.
recursive 6 hours ago [-]
Immutability is also overrated. I mostly blame react for that. It has done a lot to push the idea that all state and model objects should be immutable. Immutability does have advantages in some contexts. But it's one tool. If that's your only hammer, you are missing other advantages.
drob518 6 hours ago [-]
The only benefit to mutability is efficiency. If you make immutability cheap, you almost never need mutability. When you do, it’s easy enough to expose mechanisms that bypass immutability. For instance in Clojure, all values are immutable by default. Sometimes, you really want more efficiency and Clojure provides its concept of “transients”[1] which allow for limited modification of structures where that’s helpful. But even then, Clojure enforces some discipline on the programmer and the expectation is that transient structures will be converted back to immutable (persistent) structures once the modifications are complete. In practice, there’s rarely a reason to use transients. I’ve written a lot of Clojure code for 15 years and only reached for it a couple of times.

[1] https://clojure.org/reference/transients

WolfOliver 6 hours ago [-]
exactly, react could not deal with mutable object so they decided to make immutability seem to be something that if you did not use before you did not understood programming.
iLemming 6 hours ago [-]
Immutability is really valuable for most application logic, especially:

- State management

- Concurrency

- Testing

- Reasoning about code flow

Not a panacea, but calling it "overrated" usually means "I haven't felt its benefits yet" or "I'm optimizing for the wrong thing"

Also, experiencing immutability benefits in a mutable-first language can feel like 'meh'. In immutable-first languages - Clojure, Haskell, Elixir immutability feels like a superpower. In Javascript, it feels like a chore.

recursive 5 hours ago [-]
> Not a panacea, but calling it "overrated" usually means "I haven't felt its benefits yet" or "I'm optimizing for the wrong thing"

I think immutability is good, and should be highly rated. Just not as highly rated as it is. I like immutable structures and use them frequently. However, I sometimes think the best solution is one that involves a mutable data structure, which is heresy in some circles. That's what I mean by over-rated.

Also, kind of unrelated, but "state management" is another term popularized by react. Almost all programming is state management. Early on, react had no good answer for making information available across a big component tree. So they came up with this idea called "state management" and said that react was not concerned with it. That's not a limitation of the framework see, it's just not part of the mission statement. That's "state management".

Almost every programming language has "state management" as part of its fundamental capabilities. And sometimes I think immutable structures are part of the best solution. Just not all the time.

iLemming 5 hours ago [-]
I think we're talking past each other.

> I like immutable structures and use them frequently.

Are you talking about immutable structures in Clojure(script)/Haskell/Elixir, or TS/JS? Because like I said - the difference in experience can be quite drastic. Especially in the context of state management. Mutable state is the source of many different bugs and frustration. Sometimes it feels that I don't even have to think of those in Clojure(script) - it's like the entire class of problems simply is non-existent.

recursive 3 hours ago [-]
Of the languages you listed, I've really only used TS/JS significantly. Years ago, I made a half-hearted attempt to learn Haskell, but got stuck on vocabulary early on. I don't have much energy to try again at the moment.

Anyway, regardless of the capabilities of the language, some things work better with mutable structures. Consider a histogram function. It takes a sequence of elements, and returns tuples of (element, count). I'm not aware of an immutable algorithm that can do that in O(n) like the trivial algorithm using a key-value map.

iLemming 3 hours ago [-]
> I made a half-hearted attempt to learn Haskell

Try Clojure(script) - everything that felt confusing in Haskell becomes crystal clear, I promise.

> Consider a histogram function.

You can absolutely do this efficiently with immutable structures in Clojure, something like

      (reduce (fn [acc x]
                (update acc x (fn [v] (inc (or v 0)))))
              {}
              coll)
This is O(n) and uses immutable maps. The key insight: immutability in Clojure doesn't mean inefficiency. Each `update` returns a new map, but:

1. Persistent data structures share structure under the hood - they don't copy everything

2. The algorithmic complexity is the same as mutable approaches

3. You get thread-safety and easier reasoning for a bonus

In JS/TS, you'd need a mutable object - JS makes mutability efficient, so immutability feels awkward.

But Clojure's immutable structures are designed for this shit - they're not slow copies, they're efficient data structures optimized for functional programming.

pka 2 hours ago [-]
> immutability in Clojure doesn't mean inefficiency.

You are still doing a gazillion allocations compared to:

  for (let i = 0; i < data.length; i++) { hist[data[i]]++; }
But apart from that the mutable code in many cases is just much clearer compared to something like your fold above. Sometimes it's genuinely easier to assemble a data structure "as you go" instead of from the "bottom up" as in FP.
iLemming 1 hours ago [-]
The allocation overhead rarely matters in practice - in some cases it does. For majority of "general-purpose" tasks like web-services, etc. it doesn't - GC is extremely fast; allocations are cheap on modern VMs.

The second point I don't even buy anymore - once you're used to `reduce`, it's equally (if not more) readable. Besides, in practice you don't typically use it - there are tons of helper functions in core library to deal with data, I'd probably use `(frequencies coll)` - I just didn't even mentioned it so it didn't feel like I'm cheating. One function call - still O(n), idiomatic, no reduce boilerplate, intent is crystal clear. Aggressively optimized under the hood and far more readable.

Let's not get into strawman olympics - I'm not selling snake oil. Clojure wasn't written in some garage by a grad student last week - it's a mature and battle-tested language endorsed by many renowned CS people, there are tons of companies using it in production. In the context of (im)mutability it clearly demonstrates incontestable, pragmatic benefits. Yes, of course, it's not a silver bullet, nothing is. There are legitimate cases where it's not a good choice, but you can argue that point pretty much about any tool.

pka 42 minutes ago [-]
If there was a language that didn't require pure and impure code to look different but still tracked mutability at the type level like the ST monad (so you can't call an impure function from a pure one) - so not Clojure - then that'd be perfect.

But as it stands immutability often feels like jumping through unnecessary hoops for little gain really.

no_wizard 5 hours ago [-]
I just want a way of doing immutability until production and let a compiler figure out how to optimize that into potentially mutable efficient code since it can on those guarantees.

No runtime cost in production is the goal

iLemming 5 hours ago [-]
> No runtime cost in production is the goal

Clojure's persistent data structures are extremely fast and memory efficient. Yes, it's technically not a complete zero-overhead, pragmatically speaking - the overhead is extremely tiny. Performance usually is not a bottleneck - typically you're I/O bound, algorithm-bound, not immutability-bound. When it truly matters, you can always drop to mutable host language structures - Clojure is a "hosted" language, it sits atop your language stack - JVM/JS/Dart, then it all depends on the runtime - when in javaland, JVM optimizations feel like blackmagicfuckery - there's JIT, escape analysis (it proves objects don't escape and stack-allocates them), dead code elimination, etc. For like 95% of use cases using immutable-first language (in this example Clojure) for perf, is absolutely almost never a problem.

Haskell is even more faster because it's pure by default, compiler optimizes aggressively.

Elixir is a bit of a different story - it might be slower than Clojure for CPU-bound work, but only because BEAM focuses on consistent (not peak) performance.

Pragmatically, for the tasks that are CPU-bound and the requirement is "absolute zero-cost immutability" - Rust is a great choice today. However, the trade off is that development cycle is dramatically slower in Rust, that compared to Clojure. REPL-driven nature of Clojure allows you to prototype and build very fast.

From many different utilitarian points, Clojure is enormously practical language, I highly recommend getting some familiarity with it, even if it feels very niche today. I think it was Stu Halloway who said something like: "when Python was the same age of Clojure, it was also a niche language"

drob518 6 hours ago [-]
> Also, experiencing immutability benefits in a mutable-first language can feel like 'meh'.

I felt that way in the latest versions of Scheme, even. It’s bolted on. In contrast, in Clojure, it’s extremely fundamental and baked in from the start.

marcelr 5 hours ago [-]
programming with immutability has been best practices in js/ts for almost a decade

however, enforcing it is somewhat difficult & there are still quite a bit lacking with working with plain objects or maps/sets.

gspencley 2 hours ago [-]
We shouldn't forget that there are trade-offs, however. And it depends on the language's runtime in question.

As we all know, TypeScript is a super-set of JavaScript so at the end of the day your code is running in V8, JSCore or SpiderMonkey - depending on what browser the end user is using, as an interpreted language. It is also a loosely typed language with zero concept of immutability at the native runtime level.

And immutability in JavaScript, without native support that we could hopefully see in some hypothetical future version of EcmaScript, has the potential to impact runtime performance.

I work for a SaaS company that makes a B2B web application that has over 4 million lines of TypeScript code. It shouldn't surprise anyone to learn that we are pushing the browser to its limits and are learning a lot about scalability. One of my team-mates is a performance engineer who has code checked into Chrome and will often show us what our JavaScript code is doing in the V8 source code.

One expensive operation in JavaScript is cloning objects, which includes arrays in JavaScript. If you do that a lot.. if, say, you're using something like Redux or ngrx where immutability is a design goal and so you're cloning your application's runtime state object with each and every single state change, you are extremely de-optimized for performance depending on how much state you are holding onto.

And, for better or worse, there is a push towards making web applications as stateful as native desktop applications. Gone are the days where your servers can own your state and your clients just be "dumb" presentation and views. Businesses want full "offline mode." The relationship is shifting to one where your backends are becoming leaner .. in some cases being reduced to storage engines, while the bulk of your application's implementation happens in the client. Not because we engineers want to, but because the business goal necessitates it.

Then consider the spread operator, and how much you might see it in TypeScript code:

const foo = {

  ...bar, // clones bar, so your N-value of this simple expression is pegged to how large this object is

  newPropertyValue,
};

// same thing, clones original array in order to push a single item, because "immutability is good, because I was told it is"

const foo = [...array, newItem];

And then consider all of the "immutable" Array functions like .reduce(), .map(), .filter()

They're nice, syntactically ... I love them from a code maintenance and readability point of view. But I'm coming across "intermediate" web developers who don't know how to write a classic for-loop and will make an O(N) operation into an O(N^3) because they're chaining these together with no consideration for the performance impact.

And of course you can write performant code or non-performant code in any language. And I am the first to preach that you should write clean, easy to maintain code and then profile to discover your bottlenecks and optimize accordingly. But that doesn't change the fact that JavaScript has no native immutability and the way to write immutable JavaScript will put you in a position where performance is going to be worse overall because the tools you are forced to reach for, as matter of course, are themselves inherently de-optimized.

alan-jordan13 2 hours ago [-]
Interesting experiment. Making TypeScript immutable by default could reduce bugs and encourage cleaner design patterns. Curious to see how this approach scales in real projects.
Waterluvian 2 hours ago [-]
I love this idea so so much. I have maybe 100k lines of code that's almost all immutable, which is mostly run on the honor system. Because if you use `readonly` or `ReadOnlyDeep` or whatnot, they tend to proliferate like a virus through your codebase (unless I'm doing it wrong...)
zelphirkalt 53 minutes ago [-]
Definitely need purely functional data structures then. Is there a rich ecosystem for that for TypeScript?
epolanski 21 minutes ago [-]
fp-ts is the strictest fp implementation in typescript land.

https://gcanti.github.io/fp-ts/modules/

But the most popular functional ecosystem is effect-ts, but it does it's best to _hide_ the functional part, in the same spirit of ZIO.

https://effect.website/

hyperrail 3 hours ago [-]
Aside: Why do we use the terms "mutable" and "immutable" to describe those concepts? I feel they are needlessly hard to say and too easily confused when reading and writing.

I say "read-write" or "writable" and "writability" for "mutable" and "mutability", and "read-only" and "read-only-ness" for "immutable" and "immutability". Typically, I make exceptions only when the language has multiple similar immutability-like concepts for which the precise terms are the only real option to avoid confusion.

forty 38 minutes ago [-]
Read only does not carry (to me) the fact that something cannot change, just that I cannot make it change. For example you could make a read only facade to a mutable object, that would not make it immutable.
afandian 2 hours ago [-]
"read-only-ness" is much more of a mouthful than "immutable"!

Generally immutability is also a programming style that comes with language constructs and efficient data structures.

Whereas 'read-only' (to me) is just a way of describing a variable or object.

Waterluvian 2 hours ago [-]
Same reason doors say PUSH and PULL instead of PUSH and YANK. We enjoy watching people faceplant into doors... er... it's not a sufficiently real problem to compel people to start doing something differently.
phplovesong 7 hours ago [-]
Sounds easier to just use some other compile to js languge, its not like there are no other options out there.
epolanski 18 minutes ago [-]
Not if you want to use typescript.
k__ 7 hours ago [-]
I'm still mad about Reason/ReScript for fumbling the bag here.
petejodo 7 hours ago [-]
Agreed. Gleam is a great one that targets JavaScript and outputs easy to read code
Too 2 hours ago [-]
Rust compiles to wasm right?
vips7L 3 hours ago [-]
ScalaJs!
tyleo 7 hours ago [-]
This is tangential but one thing that bothers me about C# is that you can declare a `readonly struct` but not a `readonly class`. You can also declare an `in` param to specify a passed-in `struct` can’t be mutated but again there’s nothing for `class`.

It may be beside the point. In my experience, the best developers in corporate environments care about things like this but for the masses it’s mutable code and global state all the way down. Delivering features quickly with poor practices is often easier to reward than late but robust projects.

WorldMaker 4 hours ago [-]
`readonly class` exists in C# today and is called (just) `record`.

`in` already implies the reference cannot be mutated, which is the bit that actually passes to the function. (Also the only reason you would need `in` and not just a normal function parameter for a class.) If you want to assert the function is given only a `record` there's no type constraint for that today, but you'd mostly only need such a type constraint if you are doing Reflection and Reflection would already tell you there are no public setters on any `record` you pass it.

bilekas 7 hours ago [-]
I'm not sure if it's what you mean, but can't you have all your properties without a setter, and only init them inside the constructor for example ?

Would your 'readonly' annotation dictate that at compile time ?

eg

class Test {

  private readonly string _testString {get;}


  public Test(string tstSrting) 
      => _testString = tstSrting ;
}

We may be going off topic though. As I understand objects in typescript/js are explicitly mutable as expected to be via the interpertor. But will try and play with it.

vips7L 3 hours ago [-]
I think you would want to use an init only property for your example

    class Test {
        public string Test { get; init; }
    }

I'm not a C# expert though, and there seems to be many ways to do the same thing.
bilekas 2 hours ago [-]
I don't use the init decorator myself but I would hazard a guess it's similar. Don't quote me on that though.

The point does stand though, outside of modifying properties I'm not sure what a "private" class itself achieves.

manoDev 6 hours ago [-]
> That should make arr[1] possible but arr[1] = 9 impossible.

I believe you want `=`, `push`, etc. to return a new object rather than just disallow it. Then you can make it efficient by using functional data structures.

https://www.cs.cmu.edu/~rwh/students/okasaki.pdf

inbx0 3 hours ago [-]
At TypeScript-level, I think simply disallowing them makes much more sense. You can already replace .push with .concat, .sort with .toSorted, etc. to get the non-mutating behavior so why complicate things.
eyelidlessness 6 hours ago [-]
You might want that, I might too. But it’s outside the constraints set by the post/author. They want to establish immutable semantics with unmodified TypeScript, which doesn’t have any effect on the semantics of assignment or built in prototypes.
cipehr 5 hours ago [-]
Well said. (I too want that.) I found my first reaction to `MutableArray` was "why not make it a persistent array‽"

Then took a moment to tame my disappointment and realized that the author only wants immutability checking by the typescript compiler (delineated mutation) not to change the design of their programs. A fine choice in itself.

bilekas 6 hours ago [-]
This has really irrationally interested me now, Im sure there is something there with the internal setters on TS but damn I need to test now. My thinking is that overriding the setter to evaluate if its mutable or not, the obvious approach.
WorldMaker 5 hours ago [-]
Yeah there's a lot you could do with property setter overrides in conditional types, but the tricky magic trick is somehow getting Typescript to do it by default. I've got a feeling that `object` and `{}` are just too low-level in Typescript's type system today to do those sorts of things. The `Object` in lib.d.ts is mostly for adding new prototype methods, not as much changing underlying property behavior.
edem 4 hours ago [-]
For immutability to be effective you'd also need persistent data structures (structural sharing). Otherwise you'll quickly grind to a halt.
epolanski 16 minutes ago [-]
Why would you quickly grind to a halt.
voidUpdate 8 hours ago [-]
How do immutable variables work with something like a for loop?
tantalor 7 hours ago [-]
Is TFA (or anyone else for that matter) actually concerned with "immutable variables"?

e.g., `let i = 0; i++;`

They seem to be only worried about modifying objects, not reassignment of variables.

Cthulhu_ 7 hours ago [-]
That's probably because reassignment is already covered by using `const`.

Of course, it doesn't help that the immutable modifier for Swift is `let`. But also, in Swift, if you assign a list via `let`, the list is also immutable.

voidUpdate 7 hours ago [-]
macintux 7 hours ago [-]
Erlang doesn't allow variable reassignment. Elixir apparently does, but I've never played with it.
ruined 7 hours ago [-]
typescript handles that well already
tgv 7 hours ago [-]
Unless you need the index, you can write: for (const x of iterable) { ... } or for (const attribute in keyValueMap) { ... }. However, loops often change state, so it's probably not the way to go if you can't change any variable.
Cthulhu_ 7 hours ago [-]
If you need the index, you can use .keys() or .entries() on the iterable, e.g.

    for (const [index, value] of ["a", "b", "c", "d", "e"].entries()) {
      console.log(index, value);
    }
Or forEach, or map. Basically, use a higher level language. The traditional for loop tells an interpreter "how" to do things, but unless you need the low level performance, it's better to tell it "what", that is, use more functional programming constructs. This is also the way to go for immutable variables, generally speaking.
tgv 4 hours ago [-]
There's no difference between for (x of a) stmt; and a.forEach(x => stmt), except for scope, and lack of flow control in forEach. There's no reason to prefer .forEach(). I don't see how it is "more functional."
foxygen 8 hours ago [-]
You use something else like map/filter/reduce or recursion.
turboponyy 5 hours ago [-]
`for` loops are a superfluous language feature if your collections have `map` for transformations and `forEach` for producing side effects
bastawhiz 7 hours ago [-]
Since sibling comments have pointed out the various ES5 methods and ES6 for-of loops, I'll note two things:

1. This isn't an effort to make all variables `const`. It's an effort to make all objects immutable. You can still reassign any variable, just not mutate objects on the heap (by default)

2. Recursion still works ;)

nurettin 5 hours ago [-]
They don't work. The language has to provide list and map operations to compensate.
Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact
Rendered at 22:28:33 GMT+0000 (Coordinated Universal Time) with Vercel.