Jake Jackson
Jake Jackson

Reputation: 1225

How to implement a Record with a custom Type as its Key value without having to create a field for each Type?

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.

Playground

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

Answers (1)

jcalz
jcalz

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

Playground link to code

Upvotes: 1

Related Questions