Yona Appletree
Yona Appletree

Reputation: 9142

How to set a type parameter to "nothing" in TypeScript?

In our application, we have a data "wrapper" pattern where we use a single instance of a class to represent some data that may or may not be loaded yet. Something like this:

abstract class BaseWrapper<TData> {
    protected data?: TData;
    loadedPromise: Promise<this>;
}

Then, you would implement this for a specific piece of data, like this:

interface PersonData {
    name: string;
}

class PersonWrapper extends BaseWrapper<PersonData> {
    get name() { return this.data && this.data.name }
}

Given a person:

const person: PersonWrapper = ...;

The type of person.name is string | undefined since this.data may be undefined. This is good, because it reminds us that the object may not be loaded, and we need to handle that case when we access it's members.

However, in some cases, we know the object is loaded, most commonly when the loadedPromise is fulfilled, like this:

(await person.loadedPromise).name

The type of this expression is still string | undefined even though we know the object is loaded. This is annoying, and the problem I'd like help solving.

My best idea is to parameterize BaseWrapper with an "empty" type parameter. Something like this:

abstract class BaseWrapper<TData, TEmpty = undefined> {
    protected _data?: TData;
    get data(): TData | TEmpty {
        return this._data as (TData | TEmpty);
    }
}

Then, we could do something like this:

class PersonWrapper<TEmpty = undefined> extends BaseWrapper<PersonData, TEmpty> {
    get name() { return this.data && this.data.name }

    loadedPromise: Promise<PersonWrapper<nothing>>;
    requireLoaded(): PersonWrapper<nothing> {
        if (this.data) return this as PersonWrapper<nothing>
        else throw new Error("Not loaded!")
    }
}

The issuer here is the "nothing" type. I want to be able to pass a type into the parameter such that TData | TEmpty is equal to TData. What should this type be?

Assuming that's possible, the type of (await person.loadedPromise).name would just be string, which would be awesome.

Any ideas?

Upvotes: 4

Views: 6900

Answers (1)

Ziggy
Ziggy

Reputation: 22375

One possible solution is to use the ! suffix, which assures the compiler that an optional value is available:

const name: string = (await person.loadedPromise).name!

This works in the very obvious case where the programmer "knows" that the value is loaded.


(hey, I'm riffing on your "TData | TEmpty is equal to TData" idea. You want a type that encapsulates the possible emptiness safely and transparently, without requiring you to handle the undefined case even when you know the value is defined...)

Another thought is this: rather than having the type of name be string | undefined why not have it be... string[]! Ha ha ha, bear with me!

type Maybe<T> = T[] // I know, I know... this is weird

function of <T>(value: T): Maybe<T> {
  return value === undefined ? [] : [value]
}

abstract class BaseWrapper<TData> {
    protected data?: TData;
    loadedPromise: Promise<this>;
}

interface PersonData {
    name: string;
}

class PersonWrapper extends BaseWrapper<PersonData> {
    get name() { return of(this.data && this.data.name) }
}

const person: PersonWrapper = ...;

// these typings will be inferred I'm just putting them here to 
// emphasize the fact that these will both have the same type
const unloadedName: Maybe<string> = person.name
const loadedName: Maybe<string> = (await person.loadedPromise).name

Now if you want to use either of these names...

// reduce will either return just the name, or the default value
renderUI(unloadedName.reduce(name => name, 'defaultValue')
renderUI(loadedName.reduce(name => name, 'defaultValue')

The Maybe type represents a value that may be undefined, and it has one safe and consistent interface for accessing it regardless of its state. The default value of a reduce is not an optional parameter, so there is no chance of forgetting it: you always have to deal with the undefined case, which is very transparent! If you want to do some more processing, you can do it with map.

unloadedName
  .map(name => name.toUpperCase)
  .map(name => leftPad(name, 3))
  .map(name => split('')) // <-- this actually changes the type
  .reduce(names => names.join('_'), 'W_A_T') // <-- now it's an array!

This means you can write functions like,

function doStuffWithName(name: string) { 
  // TODO implement this 

  return name
}

and call them like this:

const name = person.name
  .map(doStuffWithName)
  .reduce(name => name, 'default')

renderUI(name)

and your helper functions don't need to know that they are dealing with an optional value. Because of the way map works, if the value is missing the name will be [] and map will apply the given function 0 times... ie. safely!

If you are uncomfortable with overloading the array type like this, you can always implement Maybe as a class: it just needs map and reduce. You could even implement map and reduce on your wrapper class (since a Maybe is just wrapper for possibly undefined data).

This isn't a "typescript specific" solution with a "nothing type", but maybe it will help you find your solution!

(NOTE: This is a lot like exposing the underlying promise, which I think is also something you might want to consider)

Upvotes: 1

Related Questions