Generic branded types — possible?

Viewed 56

I am using io-ts, and I want to do this:

const TokenName = t.brand(
  t.string,
  (n: unknown): n is t.Branded<string, TokenNameBrand> =>
    typeof n === "string", // any name is legit
    "TokenName"
);
export type TokenName = t.TypeOf<typeof TokenName><something here>;

const WEIGHT = "weight" as TokenName<number>;
const SPECIES = "species" as TokenName<string>;

function evaluateToken<T>(name: TokenName<T>): T | null {
   return {
     weight: 1200,
     species: "bear"
   }[name];
}

const w = evaluateToken(WEIGHT);  // w is inferred to be a number

I can control the creation of tokens so that in fact, a token will evaluate to the right type, but I want to make it explicit in the typing.

Is this even possible?

1 Answers

There won't be a way to do exactly what you want because TypeScript doesn't support Higher Kinded Types in a way that would allow for the syntax you're describing, but I have an idea for how this could work.

First I would create a schema that is just a t.type where the strings on the left are the allowed token types and the strings on the right are the value you want to assert is stored under that token.

import * as t from 'io-ts';

const tokens = t.type({
  weight: t.number,
  species: t.string,
});

type TokenMap = t.TypeOf<typeof tokens>;

Then the TokenMap type is the total set of things that can be tokens. I think you'll need this type anyway in order to later define that dictionary you're using to evaluate the token.

Next, I added a class for the actual Token binding because when I've done this in the past with just objects the types tend to get hairy quickly. The class can keep track of the generic type parameters you are linking to your token and also gives you nominal typing guarantees similar to a Brand.

// Helper to ensure the types pointed to by the keys are correct.
type KeysWithType<Interface, Target> = {
  [K in keyof Interface]: Interface[K] extends Target ? K : never
}[keyof Interface];

class Token<Key extends KeysWithType<TokenMap, AssociatedType>, AssociatedType> {
  public constructor(public readonly key: Key) {}

  public lookupIn(mapping: TokenMap) {
    return mapping[this.key];
  }
}

Finally you can define a mapping and then hand the mapping into the token to look up the value of the type.

const fullMap: TokenMap = {
  weight: 1200,
  species: "bear",
}

const weight = new Token('weight').lookupIn(fullMap);

You could also define your evaluateToken function like:

function evaluateToken<K extends KeysWithType<TokenMap, AssociatedType>, AssociatedType>(
  token: Token<K, AssociatedType>
) {
  return {
    weight: 1200,
    species: 'bear',
  }[token.key];
}

Full Playground with a few other things.

Notice that weight is correctly type number

One thing to note about this approach is that you can talk about instances of the Token class and still get nominal typing (in the same way you would with a branded type) so you could use your WEIGHT and SPECIES constants and still have the guarantees that people are only passing around those values and not arbitrary strings.

Related