Things I wish TypeScript had. Part 1

Search for a command to run...

No comments yet. Be the first to comment.
Before we start I have been working with IndexedDB for more than 3 years. When I just started, I thought, huh, it's similar to MongoDB, easy-peasy. Quite fast I understood that: The native API is not that easy to use, you need a handy wrapper like D...

Hey! What do you know about string type in JavaScript? I believe you saw gazillion times about 1+'1' or so. In this article, I won't talk about it, but I'd suggest we cover these questions: How strings are stored? Is there a limit for the string le...
I recently saw this tweet https://twitter.com/thomasfindlay94/status/1672211922517622784: While many responses were related to missed await before fetch, I'd like to talk about a bigger issue: error handling. Let's fix an obvious error and take a cl...
This article was initially published in my custom blog, but since I migrate to hashnode, I re-visited and re-wrote it The article focuses on the event loop, the order of execution, and how developers can optimise code. The fully detailed schema: Eve...
Note: these features are not in TypeScript (Aug 2024), that's a "what if" sort of posts about the things which I wish TS had, because on my daily basis I face some difficulties because of these tiny things.
Examples in that article are artificial and not how TS works at the moment.
When you are working on utility components, infra, libraries: you need a clear API.
What is the clear API? It's not just input=>output model but also being clear about potential mis-uses. And that's where throws syntax comes in handy:
// Non-real code
function foo(x: number): number / throws TypeError {
if (x < 0) {
throw new TypeError(`Expected positive number, got ${x}`);
}
...
}
That syntax looks verbose, yet it fulfils the main goal, now, when you would use that function you actually would be required to handle that potential case. (Example from Java). Or, well, at least you get better informed!
That approach saves an engineer tons of time. Imagine:
You use a new library in project. And to understand all errors to handle you have to go either to documentation or library internals
Use code your colleague wrote
Use code you wrote years ago
Since we don't have that support, what can we do to ensure the same level or even better level?
In vast majority of cases Either monad can be the best fit. Here you can find a wonderful talk of using Either monad from friends of mine Dmitry and Artem.
In short, the example above can be replaced with:
// Real code
function foo(x: number): Either<TypeError, Number> {
if (x < 0) {
return Either.left(new TypeError(`Expected positive number, got ${x}`));
}
...
return Either.right(returnValue);
}
Pros:
โ Either allows you to separate "expected" or "designed" errors from Runtime issues.
โ Either guarantees that an engineer will be required to handle, or at least to be informed of the potential non-happy path.
Cons:
โ It's highly likely Either underneath is using Object / Class / etc. That means, you will have an additional object to process which lifetime is not that long. Creating tons of objects makes GC calls more frequent and hence slows down your application. Death by thousand cuts.
โThere is no native Either monad. You shall use 3rd party library or create your own version.
So, unless you target to have a high-performance application, Either can be a good choice to use in your application. ๐
If something throws errors I want powerful tools to handle them.
// Non-real code
try {
await foo()
} catch {
// Remember, foo can throw TypeError, right?
case (e: TypeError) {
...
}
// Generic Error
case (e: Error) {
...
}
// Plain string error
case (e: string) {
...
} default (e) { // e is any
...
}
}
Why?
Simple wrapper (you can manage to have something similar even now, but the code will be significantly more verbose).
It's easy to transpile to JS (think of native switch-case inside catch)
Language has capabilities to perform the check in most of cases we need
instanceof/typeof checksMakes code structured
As we are speaking of syntax sugar here, we can easily replace it with similar structure:
// Real code
try {
await foo()
} catch (e: any) {
switch (true) {
case e instanceof TypeError: {
// e is TypeError
...
break;
}
case e instanceof Error: {
// e is Error
...
break;
}
case typeof e === 'string': {
// e is string
...
break;
}
default: {
...
}
}
}
Pros:
โ Great TS support, in each switch-case e type will be correctly defined!
โ No need for additional library, transitions.
โ Everything works out of the box
Cons:
โ Verbose
// Real code
try {
await foo()
} catch (e: any) {
if (e instanceof TypeError) {
...
} else if (e instanceof Error) {
...
} else if (typeof e === 'string') {
...
} else {
...
}
}
That example looks cleaner, but has some common pitfalls, where the main one is You have to define if-else chain or early return from each of the case.
If we don't do that, we risk to get into e instanceof TypeError and then immediately to e instanceof Error as Error is base (generic) Error class.
If you define your own custom error, it's highly likely that Error class will extend base Error too.
In that case, we can separate Runtime errors and designed ones:
// Real code
declare function foo(): Either<TypeError | string, number>;
try {
const result = await foo();
if (result.left) {
// switch-case or if for TypeError / string
} else {
// result.right is number
}
} catch (e: any) {
// e is highly likely just a runtime error meaning:
// e instanceof Error === true
}
Pros:
โ Errors are separated between expected/designed and runtime
Cons:
โ Verbose (can be simplified, depending on Either library choice)
โ Requires 3rd party library / own solution
Please, please, please!! That's something which is discussed in TypeScript community quite a lot. E.g.: https://github.com/microsoft/TypeScript/issues/13086
People already wrote tons of workaround for that but the truth is that many workarounds are fragile. And they might be easily broken.
Just make proper .has => .get support already!
// Real code
const map = new Map<string, number>();
map.set('foo', 1);
map.set('bar', 2);
if (map.has('foo')) {
// TS error, value is number|undefined ๐
const value: number = map.get('foo');
}
if (map.has('foo')) {
// TS is happy but what the price! ๐
const value: number | undefined = map.get('foo');
}
// Workaround ๐คฎ
const value = map.get('foo');
if (value != null) {
// value: number
const result = value + 123;
}
get:// Real code
const value = map.get('foo');
if (value != null) {
// value: number
const result = value + 123;
}
Pros:
โ Simply
โ Works
Cons:
โ I still want has + get ๐ญ
Borrowed from issue:
const x = new Map<string, string>();
interface Map<K, V> {
has<CheckedString extends string>(this: Map<string, V>, key: CheckedString): this is MapWith<K, V, CheckedString>
}
interface MapWith<K, V, DefiniteKey extends K> extends Map<K, V> {
get(k: DefiniteKey): V;
get(k: K): V | undefined;
}
x.set("key", "value");
if (x.has("key")) {
const a: string = x.get("key"); // works!
}
Pros:
โ Handles what I want ๐
Cons:
โ I don't recommend that ๐
โ If we add delete between has & get, TS still will be happy, while we introduce potential bug. So, unless you are confident you handle all corner cases... Just look at that example:
const x = new Map<string, string>();
interface Map<K, V> {
has<CheckedString extends string>(this: Map<string, V>, key: CheckedString): this is MapWith<K, V, CheckedString>
}
interface MapWith<K, V, DefiniteKey extends K> extends Map<K, V> {
get(k: DefiniteKey): V;
get(k: K): V | undefined;
}
x.set("key", "value");
if (x.has("key")) {
x.delete("key"); // We manually delete an item
const a: string = x.get("key"); // TS is happy, we have issue :(
}
^-- That means, that the whole Map typing should be modified to handle that and other corner cases.
So, I'd prefer the proper solution to be the part of standard TS library.
Sometimes, you have a class and some amount of internal methods which should be accessed inside the same package/module. Like.. they are not private, but "module-visible". Example in java
MyClass.js:
// Non-real code
export class MyClass {
public foo() {
console.log('foo');
}
private bar() {
console.log('bar');
}
module baz() {
console.log('baz')
}
}
const test = new MyClass();
test.foo() // ok
test.bar() // error
test.baz() // Same module, OK
Another file.js
// Another module
import {MyClass} from './MyClass';
const test = new MyClass();
test.foo() // ok
test.bar() // error, private
test.baz() // Anotehr module, ERROR
Why?
Libraries / utils, which have internal behaviour
Clear Public API without excess methods
module-level methods inside the class will have clear access to private fields defined with # notation.
# notation defines really private fields// Real code
export interface IMyClass {
foo(): void;
}
class MyClass implements IMyClass {
public foo() {
console.log('foo');
}
private bar() {
console.log('bar');
}
baz() {
console.log('baz')
}
}
function makeMyClass(): IMyClass {
return new MyClass;
}
// Inside the same module we can use class creation through `new`:
const test = new MyClass();
test.foo() // ok
test.bar() // error
test.baz() // public, ok
Another file.js
// Another module
import {makeMyClass} from './MyClass';
// External module does not have direct access to MyClass
const test = makeMyClass(); // test is IMyClass
test.foo() // ok
test.bar() // error, not defined in interface
test.baz() // error, not defined in interface
Pros:
โ Clear public API
Cons:
โ A little bit too "boilerplatish"