Niek Janssen
Niek Janssen

Reputation: 56

Advanced interface types for maps (objects) in typescript

I am creating a proxy-based system to intercept all reads/writes to a certain model. I am having problems, however, with creating the inerface types the system represents.

The system will, on a read or write to the model, intercept the read/write with a proxy and create new proxies accordingly to be able to intercept all future read/write operations. The root (let's call it root) proxy is cast to a type the proxy represents. This may be, for example:

interface Root {
    foo: string;
    bar: Map<OtherType>;
}

where

interface Map<T> {
    [key: string]: T | T[] | (obj: T) => string;

    insert: (obj: T) => string;
    asArray: T[];
}

The problem lies in the interface Map<T>. I would like the type of the actual map to be just T. I think the problem is that when root.bar[x] is requested where x = insert, the type of the returned object is (obj: T) => string. However, all keys in the map will be a string of length 8. What i'd actually like to do is something like this:

type ID = string[8]

interface Map<T> {
    [key: ID]: T

    insert: (obj: T) => string;
    asArray: T[];
}

I cannot figure out how to:

I already looked in the typescript declarations file, where the Object type is defined. In this definition they seem to omit the map completely. Is this something that's implemented in the compiler/linter itself?

Does anyone know a fix or workaround to achieve something like this? I think it's pretty ugly to have to cast to a type T every time I do a map lookup.

Upvotes: 1

Views: 7905

Answers (1)

jcalz
jcalz

Reputation: 328362

First, your use of the word Map is problematic since, as @TJCrowder mentions, that term means something else. I will call your type MyMap in what follows.


TypeScript doesn't (currently as of v2.5) recognize any subtype of string corresponding to 8-character strings. You can do this:

type Id = string & { length: 8 };

which defines a type Id as a particular type of string, but TypeScript is not clever enough to understand when a string is an Id (so it will never automatically narrow a string type to Id), and it will not let you use Id as the index type of an object. If you want to make a string into an Id you will have to do it with a type assertion:

function toId(str: string): Id {
  if (str.length !== 8)
    throw new Error("an Id must have exactly 8 characters");
  return str as Id;
}

This might not be worth it to you.


Since TypeScript will not recognize the Id as an index type, there's no way to type MyMap<T> the way you want it: the index is a string, and so are "insert" and "asArray". The most TypeScript-friendly workaround (which plays nicely with the TypeScript type system and lets you reap its benefits) is to move the Id-indexed properties to its own object. For example:

interface TSFriendlyMyMap<T> {
  props: { [key: string]: T } // can't do key: Id
  insert: (obj: T) => Id;
  asArray: T[];
}

In this case, you would use props to hold the properties. Using the map would be a little different:

declare const reMap: TSFriendlyMyMap<RegExp>;
const id: Id = reMap.insert(/abc/);
const arr: RegExp[] = reMap.asArray; 
const regExp: RegExp = reMap.props[id] // instead of reMap[id]

but doesn't seem worse to me.


Another thing you could do is use function overloads to make MyMap a callable function instead of an indexable object:

type CallableMyMap<T> = {
  (k: 'insert'): (obj: T) => Id
  (k: 'asArray'): T[];
  (k: Id): T
}

This behaves more closely to what you wanted, in that TypeScript knows that an Id yields a T, while "insert" and "asArray" don't:

declare const reMap: CallableMyMap<RegExp>;
const id: Id = reMap('insert')(/abc/);
const arr: RegExp[] = reMap('asArray');
const regExp: RegExp = reMap(id);

But you're using function calls, which is kind of weird.


I recommend TSFriendlyMyMap over CallableMyMap, and it's a tossup whether Id is preferable to just being careful with string. But it's up to you. Anyway, hope that helps; good luck!

Upvotes: 2

Related Questions