Before starting, I wanna get this outta the way: If your reaction on reading the title was anywhere close to Hermioneās š
Donāt worry. I havenāt gone crazy, and neither will you after reading this š
Why is this so long? š¤
This article is around 20 minutes long. The reason itās so long is because it delves deep into why you should consider this approach, and has explanations for a lot of broad topics.
With that out of the way, letās begin.
Lemme break it down
Best things about TypeScript
What are the best things about TypeScript. Some things that can be listed here:
- Static type Checking
- Seamless integration with VSCode
- Futuristic. Use any syntax that isnāt even in JS and TS will convert it to something backwards-compatible.
- JS files can be converted to TS files seamlessly.
- Catches silly bugs in your code.
- Strict about code.
There can be more,
Think again!
Are all of these really the best things about TypeScript? Sure these are all really good. But the problem that comes with them is the fact that you need an additional compile step. Also, you need tooling to watch your project as the files change. That adds a boatload of configuration, dependencies, and just more and more complexity.
Duh, just use a boilerplate š
Yeah, I got it dude/tte, I can use a boilerplate from npm that will set up the right config, and all I have to do is npm start for watching and npm run build for final bundle.
But it doesnāt get rid of the added complexity that is the dependencies, watch step and build step.
For modern Web App development, itās all fine. You already have a dev server running. Throwing in a few more plugins wonāt make much difference. And tools like Snowpack and Vite completely get rid of complexities by collapsing layers(That is, they come with all the right batteries included, so you donāt have to do any config work yourselves. If that piqued your interest, check out this amazing article by Shawn āswyxā Wang about Collapsing Layers)
The problem comes when youāre trying to build your own library to be published on npm. And by library here, I refer to a non-UI library(Not Component libraries, for example).
Why non-UI library? Because you arenāt running any dev server on them by default. You have to test them everytime by reloading the page, or worse, again and again run node index.js if itās a NodeJS related library.
With libraries like these, if you include TypeScript in development process, there are some heavy drawbacks:
Watch step
You have to keep a watcher running in one terminal, and use another one to test your code. You end up with a situation where you have 2 terminals open:
Split terminals. And good luck dealing with 2 terminal windows if your terminal doesnāt split up.
Build step
This one is probably worse than the watch step, for frontend libraries. In frontend, one of the most important thing is the bundle size youāre sending to the user. The bundle size has to be kept as low as possible, so the site can load fast enough.
And this where one of TypeScriptās best feature actually becomes a handicap: TRANSPILING FOR OLDER BROWSERS
Look at this š
const arr = [1, 2, 3, 4, 5];
const newArr = [...arr, 6, 7];
Looks simple enough. Weāre just trying to create a new array from an existing array and add some items to it using the Spread operator. But if your TSConfigās target is specified as less than es2015, youāre gonna get a very weird result:
'use strict';
var __spreadArrays =
(this && this.__spreadArrays) ||
function () {
for (var s = 0, i = 0, il = arguments.length; i < il; i++)
s += arguments[i].length;
for (var r = Array(s), k = 0, i = 0; i < il; i++)
for (var a = arguments[i], j = 0, jl = a.length; j < jl; j++, k++)
r[k] = a[j];
return r;
};
var arr = [1, 2, 3, 4, 5];
var newArr = __spreadArrays(arr, [6, 7]);
Wohkay!! Thatās a tad too much. All we wanted to do was just concat an array with another. Effectively this š
const arr = [1, 2, 3, 4, 5];
const newArr = arr.concat([6, 7]);
TypeScript injected a huge function for spreading purposes. Makes sense. Spread is meant to work on not just Arrays, but more structures, and has much more complexity than the simple concat function. It makes sense for TypeScript to do so.
If youāre a library author, and targeting modern workflows, it doesnāt make sense for all this polyfilling. The target workflow, if modern enough, would already have itās own polyfilling system, and your library sending off its own polyfills will only make things worse, due to polyfill duplication. And forget polyfilling, what if the final user is targeting modern browsers? Youāll still be sending polyfills to it.
Similar is the case with using async/await:
Look at this code sample š
async function main() {
const req = await fetch('url');
const data = await req.json();
console.log(data);
}
The main logic is just 2 lines. And when you compile it using TypeScript for targets before es2017, you get this ginormous result:
'use strict';
var __awaiter =
(this && this.__awaiter) ||
function (thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P
? value
: new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator['throw'](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done
? resolve(result.value)
: adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
function main() {
return __awaiter(this, void 0, void 0, function* () {
const req = yield fetch('url');
const data = yield req.json();
console.log(data);
});
}
It creates this huge function to interop with generators. The function generated alone is 1KB. 1KB maynāt be huge, but it makes all the difference for a library like Preact, which takes pride in it being only 10kb minified.
This is a big problem.
Again, this is not a problem when youāre developing a Web App. Itās a problem when itās a library that ships polyfills that are not needed by your project.
Solution
What Iām gonna say will be quite radical. Stay with me for a while.
DONāT WRITE CODE IN TYPESCRIPT FILES
WHAT?!?!?!
Yup. If you wanna drastically decrease the complexity in your tooling, donāt write .ts files. Instead use TypeScript inside your .JS files
You lost me there, mate!
Thereās a way to use TypeScript within JavaScript files. And the only tooling it requires is:
- VSCode as the editor
- ESLint extension for VSCode
Thatās it. And thereās a very high probability that you, the reader have both of these already installed, if youāre reading this blog post.
But still, why? How credible is it?
Iāll answer that with Rich Harris, creator of Svelte and Rollupās tweet about this exact thing:
i recently discovered the JSDoc comment form of TypeScript ( typescriptlang.org/docs/handbook/⦠) and it is a goddamn revelation. i'm making last-minute refactors to election code so much more confidently than I otherwise could, but I'm not imposing TS on my coworkers
And letās have Preactās creator Jason Millerās opinion too:
We use this in most Preact projects and it's awesome. You can define complex interfaces in an external d.ts and then use/import them from JSDoc. Works super well. We basically write fully typed code, just it's valid runnable JS.
With these 2 tech giants endorsing it, and me having had a good experience with this technique, I think itās credible enough.
Using TypeScript in JavaScript files
VSCode has JSDoc built right into it.
JSDoc is a way to document your code right in there, in comments. It was initially created to generate these huge documentation sites, but later VSCode adopted it for use in intellisense.
You can get same level of intellisense by using JSDoc as you get in directly using TypeScript
Refresher on JSDoc
You write JSDoc like this:
/**
* Square a number
* @param {number} a Number to be squared
*/
function square(a) {
return a ** 2;
}
JSDoc starts with a double star(/**), not /*. VSCode will only recognize the comments as JSDoc if there are 2 stars.
On next line, we are describing what the function does. Itās a simple description.
On the next line, @param {number} a Number to be squared is used to specify that the function parameter a is of type number. The text Number to be squared is just a description of this parameter.
Letās see it in action š
VSCode inferred parameter and function return type from JSDoc itself.
function square(a: number): number;
And you get this additional description whenever you use this function.
If you wanna type a variable rather than a parameter, thatās possible too.
/** @type {string} */
const name = 'Hello';
Letās convert a TS program to JS
So letās take a look at this small TypeScript program. This is the same one I used in the predecessor of this blog post, An Ode ⤠to TypeScript
function sum(a: number, b: number) {
return a + b;
}
document.querySelector('#submit').addEventListener('click', () => {
const val1 = +(document.querySelector('#input1') as HTMLInputElement).value;
const val2 = +(document.querySelector('#input2') as HTMLInputElement).value;
console.log(sum(val1, val2));
});
So letās convert it into its JSDoc equivalent.
/**
* @param {number} a
* @param {number} b
*/
function sum(a, b) {
return a + b;
}
document.querySelector('#submit').addEventListener('click', () => {
/** @type {HTMLInputElement} */
const el1 = document.querySelector('#input1');
/** @type {HTMLInputElement} */
const el2 = document.querySelector('#input2');
const val1 = el1.value;
const val2 = el2.value;
console.log(sum(val1, val2));
});
-
sumfunction is simple enough. It has two parameters of typenumber, so we use@param {number} -
In TS, we can simply wrap a value in parenthesis and assert its type, like
(document.querySelector('#input1') as HTMLInputElement). But thereās no way to do that in JSDoc. Weād have to break that value as a separate variable and type it using@type. Look at variableel1andel2. I have broke down these elements from theval1andval2variables, so I could type them. -
val1andval2are simplyel1.valueandel2.value. VSCodeās in-built TypeScript now knows its dealing with an Input element selector, so it will provide us the right autocompletion.
Somethingās missingā¦
If you copy the above code and paste it into your own VSCode, youāll notice itāll not show any errors.
It should show errors, because unlike the original(TypeScript) version, val1 and val2 are not preceded by +, the operator to convert these values to numbers. So if these are still strings, why arenāt we getting any error?
JavaScript is a very relaxed language. It will allow anything to slip by. Thatās its strength for a beginner, but a huge pain for an expert trying to build real apps. VSCode has to respect that lax nature for JS files, because in that case, a lot of working code will be seen as incorrect by TypeScript. So VSCode is very lax about JS files. Using JSDoc, it will provide you the intellisense, but it wonāt perform any hard checking.
You can do something like this š
let data = { name: 'Puru' };
data = 'š';
This is a crime in TypeScript, but valid in JavaScript.
Enable TypeScript level strict checking in JS files
You can enable strict checking in JS files by simply adding one comment at the top of the JS file
// @ts-check
Thatās it. This is our signal to VSCode to bring all of TypeScript into the battle. Now your code will be type checked as if it was TypeScript itself.
But without any extra tooling/compile step š.
Serious TypeScript stuff
TypeScript is much more than just number and string and boolean. It has interfaces, Union Types, intersection types, helper types, declarations, and just so much more. How can we take full advantage of all these robust practices, while in JS files?
Declare d.ts files.
d.ts rocks!!
In case youāre not familiar with them, d.ts are TypeScript Declaration files, and their sole purpose is to keep Declarations in them. For example, you have a function in a JS file.
export function sum(a, b) {
return a + b;
}
You can Type this functionās parametersā types and return types inside a d.ts file:
export function sum(a: number, b: number): number;
There!! Now whenever you import and use sum function, youāll automatically get intellisense as if the original function was written in TypeScript itself.
But sometimes this isnāt enough. Just typing the parameters and return type just isnāt enough. For a great Developer Experience, youād wanna type the internal variables of your functions too, because if even one variable is missing typing, all the other variables depending on it become useless for TypeScript.
And you also wanna use advanced TypeScript features too.
So hereās a best-of-both-worlds alternative.
Declare types in d.ts, import in JSDoc
Yup. You can import TypeScript types/interfaces from a d.ts file. Into your JSDoc. See how:
So letās say weāre building an app that uses Twitter API to get data. But Twitter API response is so huge, that you can get lost in debugging errors, if you donāt have a set structure of what data can come back.
So letās declare the return type of data that might come back:
export interface IncludesMedia {
height: number;
width: number;
type: 'photo' | 'video' | 'animated_gif';
url: string;
preview_image_url: string;
media_key: string;
}
export interface ConversationIncludes {
media?: IncludesMedia[];
users: User[];
}
export interface Mention {
start: number;
end: number;
username: string;
}
export interface Hashtag {
start: number;
end: number;
tag: string;
}
export interface EntityUrl {
start: number;
end: number;
/** format: `https://t.co/[REST]` */
url: string;
expanded_url: string;
/** The possibly truncated URL */
display_url: string;
status: number;
title: string;
description: string;
unwound_url: string;
images?: {
url: string;
height: number;
width: number;
}[];
}
export interface Attachments {
poll_id?: string[];
media_keys?: string[];
}
export interface User {
username: string;
description: string;
profile_image_url: string;
verified: boolean;
location: string;
created_at: string;
name: string;
protected: boolean;
id: string;
url?: string;
public_metrics: {
followers_count: number;
following_count: number;
tweet_count: number;
listed_count: number;
};
entities?: {
url?: {
urls: EntityUrl[];
};
description?: {
urls?: EntityUrl[];
mentions?: Mention[];
hashtags?: Hashtag[];
};
};
}
export interface ConversationResponseData {
conversation_id: string;
id: string;
text: string;
author_id: string;
created_at: string;
in_reply_to_user_id: string;
public_metrics: {
retweet_count: number;
reply_count: number;
like_count: number;
quote_count: number;
};
entities?: {
mentions?: Mention[];
hashtags?: Hashtag[];
urls?: EntityUrl[];
};
referenced_tweets?: {
type: 'retweeted' | 'quoted' | 'replied_to';
id: string;
}[];
attachments?: Attachments;
}
/**
* Types from response after cleanup
*/
export interface ConversationResponse {
data: ConversationResponseData[];
includes: ConversationIncludes;
meta: {
newest_id: string;
oldest_id: string;
result_count: number;
};
errors?: any;
}
These types I actually wrote, from scratch, for an open source project I work on, Twindle. Itās an awesome project, do check it out sometime.
Donāt worry, you donāt have to wrap your head around these types completely. Just notice 2 facts here:
- Weāre declaring interfaces
- Weāre exporting them all
Now weāre gonna use these types directly in JSDoc.
So letās open up index.js, and start typing:
// @ts-check
const req = await fetch('TWITTER_API_URL');
/** @type {import('./twitter.d').ConversationResponse} */
const data = await req.json();
What is that import doing in a comment, you may ask? That import works very similarly to a dynamic import, only difference is that here itās importing all the exported types from our declaration file, assuming that file lies in the same directory as index.js file.
Next, weāre using the ConversationResponse interface from the imported file. Now our data variable has perfect types, and will offer autocompletion and errors during typing.
And all this is happening in VSCode. VSCodeās built-in TypeScript is making a typings map from the comments and offering an experience similar akin to using TypeScript itself.
And the best part, VSCode will show you autocomplete for the exported types from the module you imported. What are we devs without that hot autocomplete š¤?
Mix n Match
Youāre not just limited to interfaces. You can use type aliases, classes, all imported from the d.ts file. And not just that, you can use all kinds of type helpers and operators in JSDoc.
// Partial of imported type
/** @type {Partial<import('./twitter.d').ConversationResponse>} */
// Pick types
/** @type {Pick<import('./twitter.d').ConversationResponse>, 'data' | 'includes'>} */
// Union types
/** @type {number | string} */
// Tuple types
/** @type {[[number, number], [number, number]]} */
And a lot more!
Clean comments
You can keep your JSDoc @types clean by not having those import statements everywhere. You can create a JSDoc alias for these types at the top level of your apps, and directly use them(And the autocomplete will work in recommending those too). Weāll use JSDocās @typedef syntax here.
@typedefis used to declare complex types under a single alias. Think of it as a toned down version oftypeorinterface.
Letās a create a types.js file in top level directory of project, and the code follows:
/**
* @typedef {import(../../twitter.d).ConversationResponse} ConversationResponse
*/
Thatās it. Using it is now very clean. The above code of fetching from twitter API becomes simpler:
// @ts-check
const req = await fetch('TWITTER_API_URL');
/** @type {ConversationResponse} */
const data = await req.json();
We got entirely rid of the import here. Much cleaner.
And yes, VSCode shows autocomplete for this type alias, so you donāt have to remember the complete word.
Lastly, Generics
This topic might be the most searched for topic, because not many answers are there for using Generics in JSDoc. So letās see how to do this.
So letās say we have a generic function š
function getDataFromServer<T>(url: string, responseType: T): Promise<T> {
// Do epic shit
}
To convert this to JSDoc, lemme introduce you to anew JSDoc thing, @template. Weāll use this to define the generic type T, then use it around.
/**
* @template T
* @param {string} url
* @param {T} responseType
* @returns {Promise<T>}
*/
function getDataFromServer(url, responseType) {
// Do epic shit
}
This works. But there are 2 caveats.
-
@templateis non-standard. Itās not specified on JSDocās own documentation. Itās used internally in Googleās Closure Compilerās source code. Apparently VSCode supports it for now, so its not a problem for us. -
No type Narrowing. You canāt specify a generic type as
T extends Arrayor something. No narrowing possible in JSDoc.
Thatās it folks!! Hope you got something out of it!
Signing off!!