A recent trend has shaken up the JavaScript-TypeScript community: the anti-build-step movement. For over a decade now, a build step1 has been widely considered to be a necessary best practice in modern web development. Now more than ever, this seemingly dogmatic reality of the web development experience has been challenged.
There are many reasons for this sudden change of heart.
- The Fresh framework by Deno cited an improved developer experience due to tighter feedback loops.
- The Svelte team followed suit but motivated by the maintainer's developer experience as they migrated the project away from TypeScript in favor of plain JSDoc comments for type annotations instead.
- Most controversially, the Turbo framework dropped TypeScript support altogether after assessing that strong typing was the culprit behind poor developer experience.
One common thread ties everything together: developer experience. In this article, however, I will not argue whether TypeScript is still relevant today.2 Instead, I choose to reflect on my own journey of stepping away from TypeScript—but for a completely different reason: consumer semantics!
TypeScript Need Not Apply
TypeScript prides itself as a superset of JavaScript. As a matter of fact, TypeScript owes much of its success to this key design decision. The classic migration guide for renaming .js to .ts considerably lowered the barrier to entry as teams gradually migrated their codebases. But, it is exactly this design decision that also made me realize where TypeScript isn't necessary!
With TypeScript being a superset of JavaScript, there exists a subset of the TypeScript language that's just plain old JavaScript. As it turns out in my experience, this also happens to be the subset of TypeScript that I deal with most often—especially in application code!
Consider the following example that features two TypeScript files: add.ts (simple adder library) and main.ts (application entry point).
// add.ts
export function add(x: number, y: number): number {
return x + y;
}
// main.ts
import { add } from './add.ts';
console.log(add(1, 2)); // 3
In add.ts, the presence of type annotations makes TypeScript a necessity. On the other hand, a cursory inspection of main.ts shows us that its syntax is purely JavaScript! At its current state, main.ts need not be TypeScript; main.js is sufficient.
This central motivating example led me to re-evaluate my relationship with TypeScript. It gradually became apparent to me that TypeScript is best suited for library code while JavaScript is more appropriate for application code.
EDIT: I would like to clarify here that when I say "library code", I do not necessarily mean published NPM packages. "Library code" can refer to internally linked sub-projects in a monorepo/workspace. One may even consider imported files from the same project to be "library code". The point being: "application code" is the consumer of "library code" wherever that may be imported from (e.g., NPM, workspace, files, etc.).
Specifically, consumer code that can solely rely on type inference for type safety (i.e., no type annotations required) may thrive on JavaScript alone. This is not the case for library code which export functions that require type annotations—especially for parameters. One may even take this guideline to the extreme by removing return types from function signatures altogether in favor of return-value type inference. This is generally considered to be poor practice, though.
Nowadays, I isolate library code more aggressively. One may enforce a scheme (for example) where UI components are consumers written in JavaScript while the more complex business logic, data fetching, and data validation are imported from a TypeScript library. Of course, the libraries may be consumers themselves, in which case they may be written in plain old JavaScript as well.
Observe that such a scheme is desirable not only because it is a best practice that encourages more testable architectures, but also because it makes the separation between the JavaScript world and the TypeScript world more intentional. That is to say, I choose to write JavaScript (in consumer code) because type inference is sufficient. But, I also choose to write TypeScript everywhere else when type annotations, type aliases, and interfaces are necessary.
JavaScript First, Then TypeScript
The .js extension is no longer an indicator of antiquity, but a declaration of sufficiency in language semantics. I prefer to write plain old JavaScript because the .js extension presents itself as consumer code. The .js extension is a deliberate communication of the consumer semantics.
For more advanced cases, I upgrade from .js to .ts, but keep the TypeScript surface as minimal as possible while isolating it from the rest of the application code. Arguably, this is exactly what .d.ts declaration files are for, but I admittedly find that the developer experience for inline implementation files is more ergonomic (and less error-prone!) than duplicating function signatures in .js and .d.ts files.
Overall, I still strongly believe in TypeScript's value to the JavaScript ecosystem.3 Nowadays, I just choose to write JavaScript first wherever possible, then upgrade to TypeScript as a last resort.
-
This includes all sorts of transpiling, compiling, tree-shaking, code-splitting, bundling, etc. ↩
-
In fact, I am a huge fan of the type-safety conveniences and guarantees that TypeScript enables. ↩
-
Even without the TypeScript syntax, the benefits of type safety also extends to JSDoc annotations (which are powered by TypeScript analysis anyway). ↩
Latest comments (68)
I would argue the other direction, use typescript in your application vs. a library. I reason that if your library could be used by a js file then you need to build all the type checking into your library since it won't be validated at build time otherwise.
Using it in the app allows for quicker refactoring across the app, which is one of the best things and (I feel) is the main selling point of ts.
The main question I ask when someone wants to use ts vs js is 'What problem are you solving?' - then 'How much does this problem cost you?' - and lastly 'How much do you think going to ts is going to cost you?'
After those three you should be able to decide if you want ts. The problem is most of the time I see teams go to ts, because, well 'types' and I like types :)
Interesting direction you went with here. I have never considered that perspective. Personally, I'd still prefer TypeScript for library code just to avoid the performance hit from frequent runtime type validation.
Instead, I would use something like Zod at a centralized utility module to validate schemas during deserialization time. Then, I would use TypeScript for the rest of the library code under the assumption that the types are valid even in runtime (since I would be enforcing that all deserialization logic passes through the validation layer anyway).
When an app becomes sufficiently complex, type checking becomes extremely helpful. If you write just a standalone page, perhaps it is less useful. But I would trade safety of types and not getting unexpected data for minor convenience any time.
I must reiterate one of my comments from another thread.
Again, I do not argue to remove type safety. I am arguing for the use of a
.jsfile where TypeScript-powered type inference is sufficient.I don't understand why should one be thinking over it instead of just using one thing everywhere.. I can see the purist point of view. For me though it's a negligible concern that would be a nuisance in the long run whenever a ts feature would need to be added to the file. Also inexperienced devs will surely apply it incorrectly - the distinction between app and lib code that you propose.
I suppose so, but in my experience, I have found it easier to navigate the codebase when the distinction between entry points, consumer code, and library code is clear from the file extension alone. Bonus points for those using code editors that have fancy file icons.
Maybe if you have created your own convention it works for you. I doubt it can work with random and especially inexperienced devs. Sounds to me though that you may achieve the same or better by a good project structure.
I'd like to note that the convention I am proposing seeks to augment an already existing project structure (regardless of whether you may consider it "good"). The discussion on a "good project structure" is irrelevant here (and also beyond the scope of my article) as I only intend to add new semantics on file extensions, not impose a specific project structure (aside from the separation of library code from application code which is arguably a best practice anyway).
New devs can pick up on the existing project structure. The extra file extension semantics are ideally just a cherry on top (that may or may not guide the further refactoring of library code).
So, I definitely see the points about not having to transpile the app.
I've also found that typescript can be extremely limiting at times.
I would say the biggest issue I've had with typescript over the years is that it has not really, IMHO, accomplished fixing the problem it set out to fix. At least not as I understand the problem.
I've been developing with JavaScript for decades. Over that time, I've learned that you have to be really disciplined with JavaScript in order to write code that won't bring down your entire team and project over time. In essence, tech debt in JavaScript accrues at a higher percentage rate than many other languages.
In comes Typescript to solve this issue. Unfortunately, in my experience, it hasn't worked. When I've worked in teams with undisciplined developers in Typescript that have been allowed to get away with letting tech debt into the code, I've had even more pain points with Typescript than JavaScript. I guess the main difference would be that I see those problems faster than I do with JavaScript and perhaps that's the good part, since I can address much earlier that the project is going south. In essence, my experience with Typescript is that the interest rate on tech debt in Typescript has been even higher and compounded more frequently than with JavaScript.
It's fine for disciplined developers, but so is JavaScript.
Without that, the peer review process is really the most important thing in a team.
If you write any functions, adding types to the parameters makes the code way safer. If you're not writing any functions (doubtful) then you can get by with type inference of imports. Is a function in main.js considered "library code"?
Perhaps it could be. If type inference is sufficient (i.e., zero input parameters) or if one settles on JSDoc instead, then that function need not be removed from
main.js. But if the typing of the function becomes a little bit too complex, then perhaps it is time to consider upgrading it to a.tsfile as "library code".It seems that people cant get through their thick skull that Typescript does a lot of type inference and that nobody ever said that you should be ridiculously verbose:
Typescript !== Java.
I agree! Type inference is so powerful that in many cases,
.jsis surprisingly sufficient for type safety.There is no type safety in JavaScript. JsDoc comments and
.d.tsfiles are still typescript.Well, yes. But what I mean is that leveraging TypeScript-powered imports in plain old
.jsfiles can be sufficient for type safety (because of TypeScript's type inference). I didn't mean to say that JavaScript alone is type-safe.Ok, then I agree, although I still don't see a downside to using .ts as file extension 😜
My perspective on this, that either you use JSDoc or ts files its fine if it works for a specific project, both do type checking with typescript anyway.
But going back to just working with javascript for web applications is long term not sustainable, its a pain coming to a codebase that has plain JS (at least for me after I've worked with projects with typescript).
Regarding this, I'd like to reiterate one of my comments from another thread here.
Let’s limit the use of JS to scripting only to start with. To fix one problem created thousands other.
Javascript first: absolutely agree, when you learn the language. After that TypeScript unless you need to write code that has to run in a console or a throwaway node.js one time script.
The way you must think of TypeScript is this is a productivity tool for your IDE. You write TS, because you want to know where and how things are going to be used. Try renaming a function in a JS codebase over a TS codebase.
For TypeScript to work super effectively you have to always look at the code you write from the perspective of the user of the code and the IDE: it provides clarity to each and relative safety.
When I do data transformation I rely extremely heavily on TS telling me what is my input and what is going to be the output. I can refactor my code while I have those in place. I cannot tell you how frustrating is to work with JS code and not knowing the exact shape of an object. How I need to double-triple check and infer what the gosh darn duck that silly object has or has not. I don't want to work with large JS code bases, because they halve my productivity. Time wasted on checks that can be done by machines.
Lastly there are two evil operators for which
anyholy warrior must resist temptation:asandany. Usually people cover their lack of understanding with those keywords and make TypeScript lie. And then they blame TypeScript being bad.I want to be clear here that I'm not advocating for the outright removal of TypeScript (and types) from the codebase. My preference for the
.jsfile is motivated by the fact that much of the application code that I deal with on a daily basis is often the subset of TypeScript that is literally just plain old JavaScript. The.jsfile extension is therefore syntactically sufficient for most application code. In this case, type safety solely hinges ontsserver's type inference.I could be wrong but saying that you only need TypeScript partially in a project or the saying of using TypeScript led to poor developet experience, basically sounds like a red flag to me that those are the kind of people who don't understand the value and benefit of TypeScript and they have been forcing themselves to use it for no good reasons. You should only use it when you think it does what you need. It's either you go big or go home. You always have the choice to use whatever tools you need.
I'd like to clarify my position that I'm not advocating for the removal of TypeScript in a project. I very much rely on its type safety guarantees to enforce compile-time correctness for my applications (to some extend).
What I have presented in this article, however, is the usage of the
.jsextension in cases where full reliance on type inference is sufficient—in which case TypeScript-exclusive syntax is unnecessary. Now this.jsphenomenon is typically the case for application code, which imports the heavily typed library code written in TypeScript (i.e.,.ts). In my experience, the explicit separation of the.jsand.tsfiles thus offers clearer semantics between application/consumer code and library code, which ultimately makes it easier to navigate a codebase.I would highly encourage everyone to listen to this episode of JS Party with Rich Harris (creator of Svelte). They do a really good dive into the “war” between TS people and non-TS people, as well as the recent activity going on with TS and take very fair points for both sides. Extremely valuable information and stuff worth considering for all of us! 😀
Rich also touches on when TS is the “right” tool, and when it’s not.