Hacker News new | ask | show | jobs
by quonn 2150 days ago
I wish it would be possible to have better studies for that. I believe that static typing has huge benefits as software scales. I also believe that the type system of TypeScript is actually stronger in practice than the Java or C# one (despite theoretical weaknesses). It has the right tradeoffs (e.g. structural equivalence, being able to type strings, being able to check that all cases are handled, etc.)

It would be nice to have proper studies, but it‘s difficult to control the other variables ...

10 comments

People who like static typing seem to really like static typing.

I'm honestly not convinced it helps that much. And it seems to cost a lot to me.

I like database and API schemas though. And I like clojure.spec and function preconditions a lot.

I don’t get the cost claims. The time it takes to note which type I intend something to be is mostly either so low that I recover it via improved hints and such very quickly, or larger but only because I’m documenting something complex enough that I should have documented it anyway, whether or not I was using static types, because it’ll be hell for other people or future-me to figure out otherwise. It seems like a large time savings to me—throw in faster and more confident refactoring and stuff like that, and it’s not even close.

I just don’t get how people are working that it represents a time cost rather than a large time savings. I don’t mean that as a dig, I just mean I genuinely don’t know what that must look like. And I’ve written a lot more code in dynamic languages, and got my start there, so it’s not like I “grew up” writing Java or something like that.

I agree with you. There is a related joke (?) that goes like this:

"I don't like to waste time writing tests, because I need that time to fix bugs on production that happened because I don't write tests".

The relation to static typing is that static types are a kind of test the computer automatically writes for you.

I think the general feeling is that there are some code patterns that are safe and easy to do with dynamic typing, but impossible with simple type systems or more complex with more advanced type system.

An example would be Common Lisp's `map` function [0] (it takes a number of sequences and a function that has as many parameters as there are sequences). It would be hard to come up with a type for this in Java, and it would be a pretty complicated type in Haskell.

Another example of many people's experience with static typing is the Go style of language, where you can't write any code that works for both a list of strings and a list of numbers. This is no longer common, but it used to be very common ~10-15 years ago and many may have not looked back.

[0] http://www.lispworks.com/documentation/HyperSpec/Body/f_map....

Haskell's not too bad once you understand ZipList

http://learnyouahaskell.com/functors-applicative-functors-an...

    max <$> ZipList [1,2,3,4,5,3] <*> ZipList [5,3,1,2]  
    > [5,3,3,4]
Yeah it's not at all complicated in Haskell. I'm not sure what GP is talking about.
I replied to the parent as well, but not only is the solution the parent showed significantly more complex than the CL version, I'm not even sure it actually does what I asked.

More explicitly, the expression there seems to rely on knowing the arity of the function and the number of lists at compile time. Basically, I was asking for a function cl_map such that:

    cl_map foo [xs:[ys:[zs:...]]] = foo <$> xs <*> ys <*> zs <*> ...
Edit: found a paper explaining that this is not possible in Haskell, and showing how the problem is solved in Typed Scheme: https://www2.ccs.neu.edu/racket/pubs/esop09-sthf.pdf
I will start by saying it took me a while to even parse the expression you provided. Whoever thought that inventing new operators is a way to write readable code should really be kept far away from programming languages. The article you provided didn't even bother to give a name to <*> and <$> so I could at least read them out to myself.

Anyway, bitter syntax sugar aside, the way you wrote the function I proposed was... a completely different function with similar results, which does not have the type I was asking for, and you only had to introduce 2 or 3 helper functions and one helper type to do it. I wanted to work with functions and lists, but now I get to learn about applicatives and ZipLists as well... no extra complication required!

Edit to ask: could this method be applied if you didn't know the number of lists and the function at compile time? CL's map would be the equivalent of a function that produces the expression you have showed me, but it's not clear to me that you could write this function in Haskell.

Edit2: found a paper explaining that this is not possible in Haskell, and showing how the problem is solved in Typed Scheme: https://www2.ccs.neu.edu/racket/pubs/esop09-sthf.pdf

In my opinion, with few exceptions, the kind of programs advocates of dynamic typing want to write that static typing would have trouble dealing with, are artificial and not the common case. (Not "map" though, I need to review that case, but "map" is definitely a common and useful function!)

> Another example of many people's experience with static typing is the Go style of language

Remember that a lot of backlash against Go's type system comes from static typing advocates used to more expressive static type systems :) It'd be a shame if, after all we complained about Go's limitations, newcomers held Go as an example of why static typing is a roadblock...

> In my opinion, with few exceptions, the kind of programs advocates of dynamic typing want to write that static typing would have trouble dealing with, are artificial and not the common case. (Not "map" though, I need to review that case, but "map" is definitely a common and useful function!)

I mostly agree, don't get me wrong. And it's important to note that Common Lisp's `map` functions do more than what people traditionally associate with `map` - they basically do `map(foo, zip(zip(list1, list2), list3)...)`.

Still, this is a pretty useful property, and it is very natural and safe to use or implement, while being impossible to give a type to in most languages.

C++ can do it with the template system, as can Rust with macros (so, using dynamic typing at compile time).

Haskell can make it look pretty decent (if you can stand operator soup) by relying on auto-currying and inline operators and a few helper functions. I would also note that the Haskell creators also though that this functionality is useful, so they implemented some of the required boilerplate in the standard lib already.

In most languages, you can implement it with lambdas and zips (or reflection, of course).

So I think that this is a nice example of a function that is not invented out of thin air, is useful, is perfectly safe and static in principle, but nevertheless is impossible to write "directly" in most statically typed languages.

Just to show the full comparison, here is how using this would look in CL, Haskell and C#:

    CL 
        (map 'list #'max3 '(1 3 5) '(-1 4 0) '(6 1 8)) 
    Haskell
        max3 <$> ZipList [1 3 5] <*> ZipList [-1,4,0] <*> ZipList [6,1,8]
        OR
        (<*>) ((<*>) ((<$>) max3 (ZipList [1,2])) (ZipList [-1,4])) (ZipList [3,1])
    C#
        new int[]{1,3,5}.Zip<int,int,Func<int,int>>(new int[]{-1,4,0}, (a,b) => (c) => max3(a, b, c)).Zip(new int[]{6, 1, 8}, (foo,c) => foo(c))
Note only the CL version, out of all these languages, can work for a function known at runtime instead of compile-time. None of the static type systems in common use can specify the type of this function, as they can't abstract over function arity.

Here's a paper showing how this was handled in Typed Scheme: https://www2.ccs.neu.edu/racket/pubs/esop09-sthf.pdf

IMO, Go is never a good example in static vs dynamic type system discussions (I mean, for this case: parametric polymorphism has been around since the 70s...).

The language developers themselves have repeatedly stated that its type system being very limited is intentional.

See e.g. here: https://github.com/golang/go/issues/29649#issuecomment-45482...

TBH, sometimes I wonder why they bothered with static typing at all...

Sure, I know Go is a low blow to static typing. But in this particular regard, Java or C# don't fare much better either.

This is not a question of just supporting parametric polymorphism, but of abstracting over the number of arguments of a function, which is not supported in almost any type system I know of; and then of matching the number of arguments received with the type of function you specified initially.

As an example of this, I've been working through Crafting Interpreters off and on. Chapter 5 consists mostly of discussion of the visitor pattern (is this the same thing as double dispatch?). The author notices that the amount of code that must be written to implement the design is so large that it's best to write a program to generate all of that code. I followed along as best I could, and at the end I wrote the equivalent code in my preferred language, which I've included in this comment:

  self[expr.type](self, expr)
It's not that difficult to do in Scala, which is probably the language that comes most close to a mainstream language that has a typesystem powerful enough to express this.

There are better languages for expressing this more natural (such as Idris) but in the end, the fallacy seems to lie in your claim that this would be "safe and easy to do with dynamic typing". That's what you think until you find out that your solution works in 99% of the cases, except in some special cases, because the compiler didn't have your back.

Examples are the standard sort functions in Java and python, which were bugged for a very long time.

Btw, here is the executable code in Scala: https://scalafiddle.io/sf/UrDu12b/1

Posting it for reference in case Scalafiddle is down:

  import shapeless._, ops.function._
  
  def multiMap[InputTypes <: HList, MapF, HListF, MapResult] (inputs: List[InputTypes])(mapF: MapF)
  (implicit fn: FnToProduct.Aux[MapF, InputTypes => MapResult]) = inputs.map(fn(mapF))
  
  
  val testList2Elems = List(
    3 :: "hi" :: HNil,
    5 :: "yes" :: HNil
  )
  
  multiMap(testList2Elems){ (num: Int, str: String) =>
    s"$num times $str is ${List.fill(num)(str).mkString}"
  }.foreach(println)
  
  
  val testList3Elems = List(
    3 :: "hi" :: 3 :: HNil,
    5 :: "yes" :: 2 :: HNil,
    2 :: "easy" :: 1 :: HNil
  )
  
  multiMap(testList3Elems){ (num: Int, str: String, mult: Int) =>
    s"$num * $mult times $str is ${List.fill(num*mult)(str).mkString}"
  }.foreach(println)
  
  
  
  // As expected, the compiler has our back and the following does not compile
  
  val testListWrongElems = List(
    3 :: "hi" :: HNil,
    5 :: "yes" :: "ups?" :: HNil
  )
  
  /*
   * Whoops, does not compile, list shape not good for multiMap :) 
   *
  multiMap(testListWrongElems){ (num: Int, str: String) =>
    s"$num times $str is ${List.fill(num)(str).mkString}"
  }.foreach(println)
  */
  
  /*
   * Whoops, does not compile, 2-sequences vs 3 argument function :) 
   *
  multiMap(testList2Elems){ (num: Int, str: String, mult: Int) =>
    s"$num * $mult times $str is ${List.fill(num*mult)(str).mkString}"
  }.foreach(println)
  */
I just checked the documentation of lisps implementation and it is different from my code. If the input lists have a different size, the shortest list decides the result length and everything else is discarded. This is of course possible to implement in Scala too, but I think it is a very bad thing to do that which can lead to bugs quite easy. I prefer my solution in that case.
Not that complicated even in C++

template <typename... Lists, typename Func> auto map(Func && func, Lists &&... lists) -> std::vector<decltype(func(std::declval<typename std::decay<Lists>::type::value_type>()...))>;

> std::declval<typename std::decay<Lists>::type::value_type>()

Yes, nothing hard to understand or discover about that at all...

Replying to add: actually, not only is the type obscure, it also relies on knowing the lists at compile time, while the CL function can do this at runtime (note that there is no dynamic behavior, it's simply that C++'s type system can't abstract over function arity).
How many hundreds of LOC would you like to write to support serializing and deserializing JSON for an endpoint that has a schema with around 20 fields, some of which are nested? If you are using Spring and Jackson, you will get to write around 300 LOC across 8 files before you get your hands on a single deserialized object. In any sane language you would use a library that enforces an arbitrary JSON schema to get the same validation guarantees provided by Jackson while writing maybe 25 LOC across maybe 2 files (if we generously count the JSON schema as code for this language but not for Java).

Is this an unusual use case?

Why would you write any of this yourself?

This is the classic use case for code generation. (And IMO one of the few justified ones.)

It seems like the more common approach among people who use Java is to write the 300 LOC across the 8 files then use the library to generate JSON schema, rather than the other way around. I wrote it myself because I did not want to tell my team that they had been doing things wrong for years before trying their approach once.
I would like to be able to explain the cost part better. It may just be personal bias of course.

1. There's no guarantee the correct theoretical model of your program fits the type system of your programming language.

2. Sometimes there are multiple correct models for different purposes in the same program, similar to how sometimes you need multiple views onto the same database tables.

3. Sometimes you just need the ability to bodge things.

> 2. Sometimes there are multiple correct models for different purposes in the same program, similar to how sometimes you need multiple views onto the same database tables.

Just wanted to point out that even though you can have multiple views or your database tables, they all still adhere to the same type system.

I guess it's a problem that can be overcome with type inference then? (I don't have to declare types on queries, updates, or views, just on the base tables.)
I think it gives people a sense of satisfaction in modeling real world in the relations between classes. The assertion seems to be that if to solve a problem it has to be correctly modelled into the type system of the language. Once the modelling is done correctly solution will arise by itself.

On the other end people who prefer weakly typed languages see problems as primarily that of data transformation. For example from HTML Form to Http Request to SQL Db to CSV File and so on.

Both approaches are differentiated by the perspective on the problem.

Please don't use weak/strong to denote type systems. Those terms are highly subjective and even non-technical people would quickly form an opinion about which is better. (Strong is good, weak is bad.) Static/dynamic is more accurate and less opinionated terminology.
They are not the same, at least according to the definitions I'm familiar with.

Static/dynamic is whether type checking is done at compile time or run type.

Strong/weak is how flexible the language is with type conversion.

Another explanation: https://en.hexlet.io/courses/intro_to_programming/lessons/ty...

Are these dimensions orthogonal though? For example is there any Strongly typed dynamic language?
Python is considered strongly typed, but usually it's placed in opposition with PHP or JavaScript. That's why I don't understand why the previous poster used strongly typed. The conversation was about static and dynamic programming languages.
Yes. Completely orthogonal.

Strongly typed dynamic: Python

Weakly typed dynamic: Javascript

Strongly typed static: Haskell

Weakly typed static: C

I think Python is mentioned as the usual example.
Static vs dynamic is not for type checking, it's about conversion.

As in:

Static typing - 1 == "1" is false (Python style, integer isn't converted to string for comparison)

Dynamic typing - 1 == "1" is true (PHP style, integer or string may be converted)

Where are you getting your definitions from? What you posted is what I understand to be weak vs strong.
But I rarely use classes in TypeScript. I do think of things as data transformations but the types certainly help a lot.
I hated statix typing until I used Rust. Rust has Sum types (super powered enums), which provide what I was missing from dynamically typed langauges in languages like Java, C#, etc: namely the ability to have an "or" type (e.g. this is an integer or a string, and I want to be able to branch on that at runtime).

That, plus type inference makes the static typing pretty painless.

Agreed. Do note that many other languages before Rust do provide good static typing with the niceties you'd expect (and that are often missing from Java), such as type inference, sum types, etc. Some examples include, but are not limited to, Scala, Haskell, the ML family of languages, etc.
Could not agree more. That's why I'm going to Rust next once done with my current F# project - I want to have experience with both JIT and AOT languages that support sum types and Option / Discriminated Unions.

These data types are much more powerful than the fancy arrays that wowed me back in the day :)

In my experience both the benefits and costs of static typing are overstated. It doesn't mean that your code works if it compiles, and it doesn't mean that you can get rid of half your tests. But it's great for refactoring, and it's a useful form of documentation that can't lie, unlike comments. And if you're using a reasonable language it's not much additional work to add types; often with type inference it's zero work.
Clojure spec seems like the way to go. I really like that it defines what should be going in and out while leaving it really easy to merge incoming data without having to write a bunch of extra code.
For me it depends on the style of static types. I do find Java or C#'s static types to be helpful, but also time consuming. Elm or Haskell on the other hand don't force me to write out the static types while still giving me the benefits of them.

There's also the case that I find the type systems of Rust, Elm, etc to be much more helpful than the type systems of C++ or Sorbet (type system for Ruby).

It depends on the situation. I've had code that absolutely benefited from static types and it helped me find bugs before they happened.

But my current job has very, very little that would benefit from static typing. Adding it into the mix would slow us down, both literally and figuratively.

> But my current job has very, very little that would benefit from static typing

Would you mind expanding on this? I'd be interested in what processes you have and if you use any additional tooling.

Thanks!

Most of what we do is just data input and data display. And most of that is text. There aren't really any calculations or anything, beyond some simple sizing of UI stuff.

For database stuff, an ORM with some validation rules is generally enough, and couldn't be replaced with static typing anyhow.

For anything that absolutely has to be a certain kind of data, there are things built into dynamic languages to check the type of something, and you just call it as needed on a case-by-case basis.

I think it’s the wrong metric to look at. Static typing still leaves plenty of room for bugs. The capability and discipline of the team would likely be more of a factor than the type system so the studies would be hard to get right.

However I do think static typing provides an enormous benefit to picking up code that is 5 years old and written by someone else. The ability to see “this is a nonnullable int32 value type” greatly reduces the amount of paths you have to go down when you have to change something or understand what’s going wrong with it. Tradeoff is you end up with a lot more code to maintain...

It helps a lot with refactoring and many other things even early in the project.

For example I‘m using TypeScript with a GraphQL code generator. Now let‘s assume I add a new value to a GraphQL enum. I run codegen, then fix everything until the compiler is happy. Afterwards, all places where this enum was ever touched will take it into account correctly, including mappings, translations, all switch statements, conditions, lists where some of the other values are mentioned and so on.

This is something that‘s not possible in a dynamic language and it‘s not even possible in Java, really.

I rely on this daily.

You could do the same process if you used dynamic typing and good test coverage. Just make your change and keep fixing tests until everything is green.
But how could you have written tests against that new enum value if it didn't exist before? You would need to know in advance which places needed to be tested for it.
Then you essentially are implementing a type checker in your tests, but worse
I would change that "5 years" to "5 months" (or maybe even 5 weeks), and argue that you can just as easily be that "someone else" on a long enough time-scale (and I don't mean 5 years.)
That hits closer to home than I'd like to admit. :)
I just can't imagine how strong types can be viewed as anything other than an immense benefit. Maybe on some personal projects or a throwaway prototype or simple scripts it might not be worth the effort, but if you're working on any kind of software that needs to be maintained over a period of years, having strong types is always better than not having them. Strong types encode extremely valuable information about the composition of application data structures, omitting them can save time up front, but it's just a form of technical debt that every engineer on the project will have to pay back when they're tasked with debugging data errors in code they didn't write. So many engineering-hours wasted by developers stepping through a debugger trying to figure out why some complex object is arbitrarily missing certain properties or why the same property is sometimes a string but other times its an object with its own potential superposition of states. It's a nightmare.
The study IMHO is reality. Every large, actively maintained system I've ever heard of uses a strongly typed language, or was written in a dynamically typed language but has converted to a gradually typed language. You can't safely refactor without compile time type checking, and you can't maintain a non-trivial system over the long-term if you can't refactor it.
A contrary anecdata - I've worked at a bunch of places and know people who work at others that have large, actively maintained, decades-old systems using, e.g., Perl. The only one I know that was actively trying to migrate recently was looking at node instead.
Just write tests and suddenly refactoring is possible without static typing. Those tests will also cover type checking concerns implicitly.
Tests can't replace types, just like types can't replace tests. You need both.

Types can't check the correctness of everything, but they do prove that certain classes of errors don't exist in your program.

Tests, on the other hand, can test for many more types of bugs, but they can only look for errors, they can't prove correctness (except in very small, closed environments where you can literally test every possible combination of inputs and outputs).

> they do prove that certain classes of errors don't exist in your program

That's particularly important when refactoring because you want to assert that you haven't introduced new bugs, and the type system will often let you prove that with almost zero effort on your part.

Yeah, except your tests can never prove that your code is fully sound, whereas a good type system can (for some definition of soundness)
If you add a new value to an enum, or a new argument to a method, you have to know all the places where you do switches on the enum or call the method, which is hard to do without static types.
Why couldn't you just search for where that enum is referenced and add the cases?
You have to know when semantically it's an instance of the enum, and when it's just a string literal (e.g. in JavaScript, where enums are just a pre-defined set of allowed strings). Also, you might assign to it in one place, and then use the resulting variable in many other places farther down the call chain. Now you have to find all those usages.

Static typing makes both of those trivial if your language (or linter) has enum-exhaustiveness checks for switch statements.

You can't always find everything with a search in a dynamic language. Some things are resolved at runtime. In our Perl code base, finding function calls is difficult for this reason (you can eval a string name to get a module or function and call it, based on a configuration setting in a file).
Repeat after me:

Tests are existentially quantified, types are universally quantified.

(Yeah, existential types, I know. Shut up. ;) )

In my personal opinion (based on personal observation) static typing helps to become more lazy and trusting since it enables features such as autocomplete, type hinting and so on where one basically gives away understanding of detail. Don't get me wrong! I love being lazy and trusting because it allows to leverage more code than I'd be able to produce on my own, but usually it's also the source of many of my own mistakes and failures. I strongly believe that I'm not the only one.
This is a surprising argument to me. In my opinion, the opposite is true: thinking about types makes you understand your building blocks better, whereas the danger of dynamic typing is that you can go a long way being "lazy" and not understanding/caring about the types involved, "it just works". Sometimes this speed is welcome, but (in my opinion) you end up crashing and burning sooner or later...
And I feel that I'm wasting time on understanding the stuff that I need to pass and have to remember 100 times more things, than I need to.

I want my code to be clear and with certain expectations fulfilled, rather than a mystery in front of me. I'm not there to learn what could be passed into my functions - I'm there to create functionality.

I've recently learned and ported a few projects from Javascript to TypeScript and I'll vouch that, so far, it is much better and easier to reason about my code and what it's doing. I also feel I need less test cases to adequately test my code.

In saying that, I'm interested in if there is any accepted, peer reviewed literature with quantifiable data as to whether strongly typed languages are "better" (whatever the study might define as better such as being faster, more scalable, etc). From what I've heard and read, most of the better-ness that strong typing provides is related to people problems and being able to scale a team, not necessarily scaling a system or making the system better. When learning Go and TypeScript after primarily writing Ruby and Javascript, I'm convinced of the better-ness strong typing provides whether it's related to readability, better IDE intellisense, or speed (although Go for example is faster then Ruby and JS not just because it's strongly typed, but compiled), I'm just interested in if there's real data to support using them instead of anecdata.

How much would you attribute that to the fact that you rewrote the code? I know my second pass is always better.
JS -> TS is usually just a matter of adding types, so I think by "ported" the previous commenter just meant adding types and tweaking as needed to make the type system happy.
Here's the closest thing I could find:

The TypeScript Tax: A Cost vs. Benefit Analysis - https://medium.com/javascript-scene/the-typescript-tax-132ff...

The author leans against TypeScript, but does cite some relevant studies on the benefits.

---

One of the cited articles:

To Type or Not to Type: Quantifying Detectable Bugs in JavaScript - http://earlbarr.com/publications/typestudy.pdf (PDF)

I much prefer static typing - but the metric used in the article is around bug reduction. Regardless of static (Go or Java), dynamic (JS), or a bit weird (plpgsql), I don’t generally get the type of a variable or object wrong - because when I’m using the object I necessarily must already have a mental map of what it represents. It’s pretty rare to try to call the “fill()” method on a “line” object, so to speak, because I know it’s a line when I access it.

So I’d guess that the number of type related bugs in dynamic languages is just a little bit greater than in static languages, simply because it is harder to make that kind of mistake in a typed language. But as a category, they aren’t common mistakes in the first place.

I can confidently say that I’m a bit of an expert at writing bugs :) and of all the kinds of bugs I write, type related bugs are probably no where near the top of the list.

That’s not to say that static typing isn’t better - I definitely think it is. But I can also believe that it doesn’t necessarily reduce the bug count by a huge margin. (For whatever it’s worth I think the main benefits are documentation and refactoring...)

On that note, I've heard many times dynamic typing advocates say (or write) "I've seldom wrote a bug that was a type error".

But a lot of the time, their language is simply unable to encode certain properties as types, so by definition they don't think of some classes of bugs they do write as "type errors". Maybe in a statically typed languages they would have been type errors indeed!

It's as if the tool you use sometimes reinforces your blind spots: "you don't know what you don't know".

PS: anecdote is probably irrelevant, but I've written plenty of dumb type errors with Python. Things that would have been caught by a test or an external tool, sure, or the type checker of a statically typed language could have caught for me for free, leaving the more relevant logic tests to me. I tend to write type errors left and right. Maybe I'm simply not a good Python programmer, of course!

> I wish it would be possible to have better studies for that. I believe that static typing has huge benefits as software scales.

I also believe that, especially after it outscales what one person is able to (fully) overview. Static types are something a program can reason about so it allows so much more productivity boosting tooling to be created. This also goes way beyond simply catching type errors at compile time vs. runtime (a downside which can largely be mitigated by test coverage). Just look what an IDE for e.g. Java can do simply in helping you navigating a big codebase. Then throw in refactoring which is in many cases can even be a completely automated operation and in much more cases is at least greatly assisted by the tools. Tools for dynamic languages can often at most guess, making good guesses is hard so in practice you get mostly stuff which is pretty limited in its usefulness.

I'd love for the study to include various types of static types. Testing against only C#/Java style type systems seems fairly narrow compared to the various kinda of static type systems available.
I've contributed to large codebases that have static typing (C++ and TypeScript) and dynamic typing (JavaScript) and I've come to the conclusion over the years that static typing isn't really worth it as long as you have the discipline to write tests for your code. The most basic unit tests cover type checking concerns. Refactoring might require a bit more search/replace but I don't see how that is a big deal. Tests make refactoring safer than with just types. Tests act as good documentation of how you expect your code to behave and what expected inputs/outputs there are. You just don't get autocomplete which is a pretty overrated feature imo.