TypeScript: Strict types in objects, getters, and setters

Out of the box typescript can infer the types of an object… roughly. Unfortunately it cannot infer the types of keys, and only infers basic types, like string and number for values, as opposed to an exact number or string.

If we were to define an object like so:

const obj = {
  foo: 'bar',
  num: 1
};

Then typescript will infer it is of type:

{
  [index: string]: string | number;
}

This is fine in most cases, but there are times when you’d like your types to exactly match your object definition, or ensure that objects are only created with exact values. Luckily this is possible with just a little bit more code.

Exact object types

First we define an interface for our object. This can be as strict as you like, but for this example we’re doing a few different things with it.

interface IThing {
  id: string;
  type: 'foo' | 'bar';
  num?: number;
}

Then we can use this Exact type to prevent type inference on our objects. This can be done inline, but it’s much nicer to abstract into a type first.

type Exact<T> = {[K in keyof T]: T[K]};

We call Exact with our objects interface. This ensures that our object is always created with the exact keys & values required by the interface.

let thing: Exact<IThing> = {
  id: 'test',
  type: 'foo',
  num: 1,
};

Now when we access the keys, all of the types of these values are the exact types defined in our interface.

let id = thing.id // string
let type = thing.type // 'foo' | 'bar'
let num = thing.num // number | undefined

Additionally, we cannot access keys that were not defined in our interface.

Exact getter & setter types

Here we’re defining an API with getters and setters for our object. The objects type (I) is inferred, and if we’re passing an Exact object, it’ll know all the interface types

function MagicMap<I> (obj: I) {

  function get<K extends keyof I> (k: K) {
    return obj[k];
  }

  function set<K extends keyof I> (k: K, value: typeof obj[K]) {
    obj[k] = value;
    return obj;
  }

  return {
    get,
    set,
  };
}

Note the K extends keyof I. In order to return the correct types we extend the keys of this object. Don’t ask me why, but if you don’t let K extend, you get less exact types (e.g. string when it should be 'foo' | 'bar').

Same thing goes for the setter, except here we are also passing a value that uses K to get the exact values that can be passed in to the setter.

Now all of the types returned by the getters are the exact types you’d expect from the interface

const magicMap = MagicMap(thing);

id = magicMap.get('id'); // string
type = magicMap.get('type'); // 'foo' | 'bar'
num = magicMap.get('num'); // number | undefined

And it wont let you set a less exact value on any key. E.g. thing’s type is 'foo' | 'bar', so simply passing another string is not allowed.

thing = magicMap.set('type', 'string');