Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Can I write a type guard that asserts multiple invariants?

Can I write a type guard asserting something about one or multiple sub-objects of an argument? In pseudo-code, it might look like this:

class C {
    a: number?;
    b: string?;

    function assertInitialized() : (this.a is number) and (this.b is string) {
        return this.a !== null && this.b !== null;
    }
}


Background: I'd commonly use a single function to check invariants of my classes; for example, assume I have a class with some nullable fields, which get asynchronously initialized.
class DbClient {
    dbUrl: string;
    dbName: string;
    dbConnection: DBConnection?;
    …
}

This class goes through a complex asynchronous initialization process, after which dbConnection becomes non-null. Users of the class must not call certain methods until dbConnection is initialized, so I have a function assertReady:

assertReady() {
    if (this.dbConnection === null) {
        throw "Connection not yet established!";
    }
}

This function assertReady is called at the beginning of each function that requires the DbClient to be fully initialized, but I still have to write non-null assertions:

fetchRecord(k: string) {
    assertReady();
    return this.dbConnection!.makeRequest(/* some request based on k */);
}

Can I give assertReady a signature that makes the ! unnecessary? I don't want to pass this.dbConnection to assertReady, because that function is typically more complex.

The only trick I'm aware of is to create an interface with the same fields as the current class, but with non-nullable types (without the ?). Then I can make a type guard that says that this is InitializedDbClient. Unfortunately that requires duplicating a large part of the class definition. Is there a better way?

like image 765
Clément Avatar asked Apr 06 '18 02:04

Clément


2 Answers

Yes you can and you almost had it exactly right in pseudocode

Interface A {
  a?: number;
  b?: string;

  hasAandB(): this is {a: number} & {b: string};
}

Notice how your pseudocode's and became an &. Very close indeed.

Of course, it's not necessary to use that operator, the type intersection operator, in this case because we can simplify it to

hasAandB(): this is {a: number, b: string};

But imagine we add a third property, say c, which isn't affected by the type guard and yet we don't want to lose its contribution to the result type all the same.

Your intuition of composable type guards brings us back full circle

hasAandB(): this is this & {a: number, b: string};

There are all sorts of really interesting, and highly useful, things you can do with these patterns.

For example, you can pass many property keys generically based on the type of an object and, depending on the actual keys that you pass, the result can be type guarded to the intersection of a bearer of each property.

function hasProperties<T, K1 extends keyof T, K2 extends keyof T>(
  x: Partial<T>,
  key1: K1,
  key2: K2
): x is Partial<T> & {[P in K1 | K2]: T[P]} {
  return key1 in x && key2 in x;
}


interface I {
  a: string;
  b: number;
  c: boolean;
}

declare let x: Partial<I>;

if (hasProperties(x, 'a', 'b')) {...}

And really, this just scratches the surface of what's possible.

Another really interesting application of this is to define arbitrarily generic and type safe builders and composable factories.

like image 114
Aluan Haddad Avatar answered Sep 30 '22 04:09

Aluan Haddad


You can't change the type of a class property after you have declared it in Typescript. Your suggestion of using a type assertion function to locally re-qualify this may have some merit:

interface DefinitelyHasFoo
{
    foo: number;
}

class MaybeHasFoo
{

    public foo: number | null;

    constructor()
    {
        this.foo = null;
    }

    public hasFoo(): this is DefinitelyHasFoo
    {
        return !!(this.foo !== null);
    }

    public doThing()
    {
        if (this.hasFoo())
        {
            this.foo.toExponential();
        }
        else
        {
            throw new Error("No foo for you!");
        }

    }
}

// ...

const mhf = new MaybeHasFoo();

mhf.doThing();
like image 22
Michael Zalla Avatar answered Sep 30 '22 02:09

Michael Zalla