Things I wish TypeScript had. Part 1

·

7 min read

Things I wish TypeScript had. Part 1

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:

  1. You use a new library in project. And to understand all errors to handle you have to go either to documentation or library internals

  2. Use code your colleague wrote

  3. 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?

  1. Simple wrapper (you can manage to have something similar even now, but the code will be significantly more verbose).

  2. It's easy to transpile to JS (think of native switch-case inside catch)

  3. Language has capabilities to perform the check in most of cases we need

    1. Yet, these limitations should be based on runtime "types" using instanceof/typeof checks
  4. 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

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"