Reputation: 1225
I have a class that has a property fruits
that is Record that maps a Fruit
to an object.
type Fruit = 'Orange' | 'Apple' | 'Banana';
class FruitHandler {
fruits: Record<Fruit, Object>;
constructor() { this.fruits = {} /* 1st error */ }
public createFruit(fruit: Fruit): Object {
if (this.fruits[fruit]) return this.fruits[fruit] /* 2nd error */
const _fruit = new Object();
this.fruits[fruit] = _fruit;
return _fruit
}
}
The issue I am having is that when I initialise the fruits
property, I get the following error:
Type '{}' is missing the following properties from type 'Record<Fruit, Object>': Orange, Apple, and Banana.
If I change the property of fruits
to be Partial, i.e. fruits: Partial<Record<Fruit, Object>>
, I get a different when I try to return the object associated in the fruit:
Type 'Fruit | undefined' is not assignable to type 'Fruit'.
Type 'undefined' is not assignable to type 'Fruit'
I'm clearly not understanding something.
Would somebody be able to explain what it is that I am missing? And how I might be able to go about implementing what it is that I am looking for: a Record with varying number of values, without having to implement each and every Type that I have typed as its Key.
EDIT: I have a working - but not ideal - solution:
private fruits: Partial<Record<Fruit, Object>>;
...
public createFruit(fruit: Fruit): Object {
if (this.fruits[fruit]) return this.fruits[fruit] as Fruit;
This has gotten rid of the error, but it obviously isn't something I want to do if it can be helped.
Upvotes: 1
Views: 778
Reputation: 327954
You definitely want fruits
to be a Partial<Record<Fruit, Object>>
as opposed to a Record<Fruit, Object>
. The Partial<T>
utility type expresses a type where all the properties are optional,
class FruitHandler {
fruits: Partial<Record<Fruit, Object>>; // <-- need this
constructor() { this.fruits = {} }
Now we have to figure out how to implement createFruit()
.
The problem you're having is that the truthiness check for this.fruits[fruit]
doesn't serve to narrow the this.fruits[fruit]
property. Because the type of fruit
is a union of literal types and not a single literal type, it does not realize that both instances of this.fruits[fruit]
necessarily refer to the same value. The type system cannot tell the difference between if (obj[k]) return obj[k]
and if (obj[k]) return obj[p]
if k
and p
are of the same type. If that type is a single string literal like "a"
, then narrowing is fine no matter what... if (obj.a) return obj.a
is obviously safe and good. But if it's a union like "a" | "b"
, then the compiler can't tell if you're writing if (obj.a) return obj.b
or vice versa, and so it doesn't let you narrow.
There is a longstanding open issue about this at microsoft/TypeScript#10530. For now, it's a missing feature.
The workaround here is to make sure you only do the property read exactly once, so the compiler doesn't see a potential for there being two different values. The easy way to do this is to save the property to a new variable before testing, like this:
public createFruit(fruit: Fruit): Object {
let _fruit = this.fruits[fruit];
if (_fruit) return _fruit; // okay
_fruit = new Object();
this.fruits[fruit] = _fruit;
return _fruit;
}
By saving the property you read into _fruit
to start with, now you can write if (_fruit) return _fruit
and the truthiness narrowing succeeds.
That works, but if you want a terser implementation you could refactor to use the nullish coalescing operator (??
):
public createFruit(fruit: Fruit): Object {
return this.fruits[fruit] ?? (this.fruits[fruit] = new Object());
}
This is essentially the same thing; if this.fruits[fruit]
isn't undefined
or null
then you return it. Otherwise, the assignment expression is evaluated which both assigns the new object to the property and returns that object.
The compiler recognizes the above as safe, since the type of x ?? y
is something like NonNullable<typeof x> | typeof y
, which in this case will be NonNullable<Object | undefined> | Object
which is Object | Object
which is Object
.
You can verify that this behaves as expected from the caller's side as well:
const x = new FruitHandler();
const b = x.createFruit("Banana"); // CREATED
console.log(b); // {}
const b2 = x.createFruit("Banana"); // not created
console.log(b === b2) // true
console.log(x.fruits.Orange) // undefined
const o = x.createFruit("Orange"); // CREATED
console.log(x.fruits.Orange) // {}
const a = x.createFruit("Apple"); // CREATED
console.log(a); // {}
console.log(a === b) // false
Upvotes: 1