Hacker News new | ask | show | jobs
by hki99 2491 days ago
During my internship we've built a large prototype using React+Typescript, and here are some of my key take aways from it:

  - Quite often when using unreleased APIs, you turn to using "any" all over the place.
  - Development time is slower than writing in regular JS/React. This started to become a major issue due to the nature of our project being a prototype (fast iterations on ideas and features). 
  - Lots of frustration when a package doesn't have types (although most major ones do have them).
Otherwise it has been a joy writing the application, and it does "document" your components significantly better.
8 comments

I think the threshold of when Typescript starts being helpful is reached very quickly. Because there's always some schema in code, yes you can assign any value in JS, but then you have to remember it and account it in other code etc. There's always some schema, you just have to keep it in your head. And with TS I can offload it to the code/IDE to help me. I want to make decision about shape of an object in the moment I'm creating that abstraction or when I'm looking specifically at it deciding if it needs to be changed. I don't want to be forced to remember all those decisions all the time. If some prototype code or script is couple of screens long, sure you can easily fit it in your head and maybe you don't need additional assistance, but when it grows larger, pretty quickly it's very nice to separate process of thinking over shape of objects and process of using them.
Development time isn't slower when you factor in all the bugs it saves you from dealing with down the road when you're either wonder what is the type of an object or why something isn't working during runtime that a compiler could have caught.
Also, if you're working with an untyped APIs, define the types you're using. It doesn't have to be complete or perfect. But having that formal contract will save you time and make explicit your assumptions. In the best case, you can contribute those types to the API to everyone's benefit.
I’d recommend using ‘unknown’ over ‘any’, it’s truer to the code you’d probably end up writing if it was vanilla JS; fairly defensive stuff.

On top of that don’t be hesitant to define ad hoc interfaces on your side of things; ultimately ‘any’ communicates zero information and provides zero defence.

The more I start to use other techniques the more I feel that ‘any’ really should be the last escape hatch deployed.

IMHO TypeScript saves a lot of time as soon as any project grows over few thousands lines of code. I'm working on a large project in which both backend and frontend are in JavaScript (node+PWA) and without TS it would have been close to impossible to proceed at the speed we did. Thanks to TS we easily know what types must be passed between client and server, and we can easily refactor or edit code without worrying that some mis-type somewhere will brake things. We don't use any anywhere (literally), it's not trivial, but as soon as you get to know TS well enough it's absolutely feasible, at least since TS 3.x.
The way we've been using typescript is that when you're first implementing the API using 'any' is just fine, but it's not ready for release until all the 'any's are removed.

What I've found is that there tends to be a happy medium between making everything 'any' at the start and never using 'any' at all that roughly corresponds to how defined our implementation is. When we're designing the implementation as we go, there tends to be lots of 'any', but when we spent time defining the interfaces, there's not as much need for using 'any', because instead there's a specific type. The type itself usually doesn't remain static, but where it's used does.

So for example, when we're adding a rest endpoint, when we know the required and optional arguments/response, we can make a type and validator function and then there's not really a need for 'any' after the validation function, but if we don't know what the arguments/response will be (or the design is still at the 'make every argument optional' stage), then any sort of prototype will be littered with 'any' or '{[key: string]: any}' types.

I'm curious, and I'm prefacing this up front because I'm not always good at writing what I say in a way that may not feel like I'm coming from a good place, so here goes:

Whats your testing story? This to me seems like not writing good, solid, abstracted tests before doing proper implementations of your code. This could be solved with good interface design, and perhaps be faster.

I apologize in advance if this sounds harsh. This sounds like the exact thing folks on my team were trying to do, and it was turning things very sub-optimal.

(Disclaimer: I'm a bit of a TDD/BDD idealogue. Not as hardcore as Uncle Bob[0], certainly, but close enough. I think writing Interfaces before Tests is acceptable, I think that might be where things differ, i guess).

[0]https://blog.cleancoder.com/

At least for interfaces between different modules/layers of abstraction, we use BDD almost exclusively. Our docs tend to be very well defined, so 'any' isn't actually used that often. We focus on end-to-end testing over unit and integration tests, which for the REST API backend means only looking at the request, the response, and the side-effects (especially db writes). For us the docs/spec comes first and then tests, implementation, and consumer use can (and does) happen in parallel.

The REST API is defined using OpenAPI v3, and we use express-openAPI to generate request and response validator functions for every endpoint. Each endpoint needs a happy-path test for 1) every optional argument supplied and 2) none of the optional arguments supplied (if there's no optional arguments then this devolves into a single test), and all side-effects must be verified. The main place where we use 'any' or {[key: string]: any} and then just cast to what's expected tends to be the responses from the database, because the response validation code will catch any actual mismatches (the most common mismatch is forgetting to parseInt and trying to send back something like '1' instead of 1, but sometimes there's issues with the db field being nullable when it shouldn't or not nullable when it should be nullable).

Here's the latest test run on master (hope the formatting works):

  ---------------------------------------------------------------|----------|----------|----------|----------|-------------------|
  File                                                           |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
  ---------------------------------------------------------------|----------|----------|----------|----------|-------------------|  
  All files                                                      |    82.89 |    63.56 |    86.38 |    82.86 |                   |
Branches are especially low, because we don't test most unhappy paths since we use a middleware error handler with generic error messages for different types of errors that's got something like >90% coverage instead of handling errors within each endpoint. The unhappy paths we do test thoroughly are things like our user-defined typeguards, our middleware error handler, our security handlers, and anything else where we validate unsafe/unknown inputs.

We try to stay away from stringent TDD/BDD unit testing, because 1) the reward from the work required to get there doesn't justify the cost of getting there for us right now and 2) strict BDD/TDD unit testing makes it much harder and slower to try different implementations/refactor things, since every time you want to modify one-off helper functions you need to add a bunch of tests first. We found that cost is worth it at module boundaries (eg endpoints, auth, database), but not for most functions that are only ever called inside their module.

>- Quite often when using unreleased APIs, you turn to using "any" all over the place.

What unreleased APIs are you needing to warrant this? We've used any a couple times, but usually just as a placeholder until the data model is locked down.

>- Development time is slower than writing in regular JS/React. This started to become a major issue due to the nature of our project being a prototype (fast iterations on ideas and features).

Again, I can't say I've had this experience. Development time is _initially_ a tiny bit slower, but once you've setup types, the time saved from fixing type related issues adds up very very fast. Also, autocomplete / autoimporting has actually sped up my development time hugely. Not having to worry about figuring out relative paths or imports and just being able to type a component to import it is magic.

>- Lots of frustration when a package doesn't have types (although most major ones do have them).

This is true, but I've found that 95% of the packages we use do have types. The few that don't, tend to be very small indie packages that don't do a lot, so the lack of types isn't a huge issue.

Interesting. There are downsides to using TypeScript (build chain complexity is the major one for me, although that's getting less and less relevant as more and more tools gain native TypeScript support), but the three you mention are not relevant to me.

- I don't know what unreleased API's you're referring to, but I generally haven't seen the need to use them - if they're unreleased, I try to avoid them.

- Especially for projects with fast iterations, TypeScript has been massively useful. Changing the API around, which I do often at the start of a project, is just so much easier when you've got TypeScript to make most of the required changes, or to tell you where you have to make changes.

- Type availability might be a problem, but I also generally stick to major packages for which it's not. But yes, I have learned to contribute to DefinitelyTyped - which luckily is a relatively smooth process.

Beyond a certain size and complexity (which is not that much), I find that the argument of typescript (and other typed languages) being less productive than untyped dynamic ones, is not true when you look at it as a whole. It might feel slower, especially to begin with, but once you get used to the language and semantics you save a vast amount of time, by the bugs you _don't_ debug and by not having to jump through the code all the time to find out what that function or module was called or what parameters it accepted. This of course is less true if you're using a lot of untyped packages, but as you said, most do have types either natively or in the DefinitelyTyped project. For most modules it's also feasible to declare the module typings manually, even doing it gradually for the parts that you happen to need at a given time.