It helps to write a type function to turns a type like {a: "A", b: "B"}
to its inverse, {A: "a", B: "b"}
. TypeScript 4.1 will make this easy with mapped type as
clauses as implemented by microsoft/TypeScript#40336:
// TS4.1+
type Invert<T extends Record<keyof T, PropertyKey>> = { [K in keyof T as T[K]]: K };
But for TypeScript 4.0 and below you can get something reasonably equivalent in most cases by using mapped conditional types:
// TS4.0-
type Invert<T extends Record<keyof T, PropertyKey>> =
{ [K in T[keyof T]]: { [P in keyof T]: K extends T[P] ? P : never }[keyof T] }
In either case, you want keyTextBijection
to take an object of type T
and return an object of type T & Invert<T>
. Since such an intersection looks ugly, I usually define a no-op mapped type called Id
which has the effect of merging the intersection into a single type object. (This Id
is also called Expand
in this question):
type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : any;
So now we can give a call signature to keyTextBijection
:
function keyTextBijection<T extends Record<keyof T, S>, S extends PropertyKey>(
map: T
): Id<T & Invert<T>>;
function keyTextBijection(map: any) {
const bijection: any = {};
Object.keys(map).forEach(key => {
bijection[key] = map[key];
bijection[map[key]] = key;
});
return bijection;
}
In the above, S
doesn't really do anything for inference purposes; it's mostly just a way to give the compiler a hint that it should try to infer the T
type as a mapping from literals to literals. When you enter {a: "A"}
as the map
argument, you want the compiler to see the type as {a: "A"}
and not as {a: string}
, since the latter will not invert properly.
And the function uses a single overload call signature to allow the implementation to be lax with type safety and use any
. (If you don't do this, you'll find yourself having to use a bunch of type assertions because the compiler won't be able to verify the type safety of the assignment statements).
Let's test it:
const bij = keyTextBijection({ a: "A", b: "B", c: "C" });
/* const bij: {
a: "A";
b: "B";
c: "C";
A: "a";
B: "b";
C: "c";
} */
console.log(bij);
/* {
"a": "A",
"A": "a",
"b": "B",
"B": "b",
"c": "C",
"C": "c"
} */
The type and the value of bij
look correct to me.
Playground link to code