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.
Throws syntax
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
Workaround?
Since we don't have that support, what can we do to ensure the same level or even better level?
Either monad
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. 👍
Typed catch syntax
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
- Yet, these limitations should be based on runtime "types" using
instanceof
/typeof
checks
- Yet, these limitations should be based on runtime "types" using
Makes code structured
Workaround?
As we are speaking of syntax sugar here, we can easily replace it with similar structure:
Using Switch-case:
// 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
Using if-else:
// 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.
Using Either monad
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
Can we finally have fully-supported Map syntax
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;
}
Workaround?
Use just 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
😭
Patch map interface
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.
Module / Package-level class access control
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.- Remember, that TypeScript access modifiers are syntax enhancements, while
#
notation defines really private fields
- Remember, that TypeScript access modifiers are syntax enhancements, while
Workaround?
Interfaces
// 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"