APixel Visuals
APixel Visuals

Reputation: 1648

TypeScript - How do you chain accessing optional nested type properties?

I have a Client class that stores caches of other objects that the application needs to keep in memory. The structure of the object's cache is developer-defined. For example, if we have a cache of Example objects:

class Example {
    property1: string;
    property2: string;
}

The developer might only want property1 cached.


import { EventEmitter } from "events";

// Constructor options for `Client`
interface ClientData {
    cacheStrategies?: CacheStrategies;
}

// How various objects should be cached
interface CacheStrategies {
    example?: ExampleCacheStrategies;
    ...
}

// Metadata for how each type of object should be cached
interface ExampleCacheStrategies {
    cacheFor?: number;
    customCache?: ExampleCustomCacheData;
}

// The custom structure of what parts of `example` objects should be cached
interface ExampleCustomCacheData {
    property1?: boolean;
    property2?: boolean;
}

// The object stored in `Client.exampleCache`, based on the custom structure defined in `ExampleCustomCacheData`
interface CustomExampleData<CachedExampleProperties extends ExampleCustomCacheData> {
    property1: CachedExampleProperties["property1"] extends true ? string /* 1 */ : undefined;
    property2: CachedExampleProperties["property2"] extends true ? string : undefined;
}

class Client<ClientOptions extends ClientData> extends EventEmitter {

    // The map's value should be based on the custom structure defined in `ExampleCustomCacheData`
    exampleCache: Map<string, CustomExampleData<ClientOptions["cacheStrategies"]["example"]["customCache"]>>;

    constructor(clientData: ClientOptions) {
        super();
        this.exampleCache = new Map();
    }
}

const client = new Client({
    cacheStrategies: {
        example: {

            /**
             * The properties of `example` objects that should be cached
             * This says that `property1` should be cached (string (1))
             */
            customCache: {
                property1: true, // (2)
                ... // (3)
            }
        }
    }
});

client.exampleCache.set("123", {
    property1: "value"
});

const exampleObject = client.exampleCache.get("123");

if (exampleObject) {

    // Should be `string` instead of `string | undefined` (2)
    console.log(exampleObject.property1);

    // `string | undefined`, as expected since it's falsey (3)
    console.log(exampleObject.property2);
}

As explained in the comments above the console.log()s, the goal is for objects that are pulled from the cache to have property1 be a string instead of string | undefined.

The problem is that exampleCache: Map<string, CustomExampleData<ClientOptions["cacheStrategies"]["example"]["customCache"]>>; doesn't work since both ClientOptions["cacheStrategies"] and ClientOptions["cacheStrategies"]["example"] are optional. The following doesn't work either:

exampleCache: Map<string, CustomExampleData<ClientOptions["cacheStrategies"]?.["example"]?.["customCache"]>>;

It errors with '>' expected at ?.. How can I solve this?

Upvotes: 1

Views: 1280

Answers (1)

jcalz
jcalz

Reputation: 327604

Syntax like the optional chaining operator ?. or the non-null assertion operator ! only applies to value expressions that will make it through to JavaScript in some form. But you need something that works with type expressions which exist only in the static type system and are erased when transpiled.

There is a NonNullable<T> utility type which is the type system analog of the non-null assertion operator. Given a union type T, the type NonNullable<T> will be the same as T but without any union members of type null or undefined:

type Foo = string | number | undefined;
type NonNullableFoo = NonNullable<Foo>;
// type NonNullableFoo = string | number

In fact, the compiler actually uses it to represent the type of an expression that has the non-null assertion operator applied to it:

function nonNullAssertion<T>(x: T) {
    const nonNullX = x!;
    // const nonNullX: NonNullable<T>
}

So, everywhere you have a type T that includes null or undefined and you would like to remove it, you can use NonNullable<T>. In your code, you will need to do it multiple times. In the interest of something like brevity (of code, not my explanation), let's use a shorter alias:

type NN<T> = NonNullable<T>;

and then

class Client<C extends ClientData> extends EventEmitter {

  exampleCache: Map<string, CustomExampleData<
      NN<NN<NN<C["cacheStrategies"]>["example"]>["customCache"]>>
  >;

}

This compiles without error, and behaves how I think you'd like it:

console.log(exampleObject.property1.toUpperCase()); // string
console.log(exampleObject.property2); // undefined

Playground link to code

Upvotes: 4

Related Questions