Reputation: 9142
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
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