0

I have a StringToString interface, which I use all over the place:

interface StringToString {
    [key: string]: string;
}

I also exchange the key and values in my objects quite regularly. This means that the keys become values, and the values become keys. This is the type signature for the function.

function inverse(o: StringToString): StringToString;

Now here's the problem: Since this exchange is done quite often, I'd like to know if the object I'm treating has keys as values or keys as keys.

This means I'd like two types:

export interface KeyToValue {
    [key: string]: string;
}
export interface ValueToKey {
    [value: string]: string;
}
type StringToString = KeyToValue | ValueToKey;

With these, the inverse function becomes:

/** Inverses a given object: keys become values and values become keys. */
export function inverse(obj: KeyToValue): ValueToKey;
export function inverse(obj: ValueToKey): KeyToValue;
export function inverse(obj: StringToString): StringToString {
    // Implementation
}

Now I'd like typescript to show errors when I'm assigning to ValueToKey to KeyToValue. Which means I'd like this to throw an error:

function foo(obj: KeyToValue) { /* ... */ }

const bar: ValueToKey = { /* ... */ };

foo(bar) // <-- THIS SHOULD throw an error

Is that possible?

ecstrema
  • 543
  • 1
  • 5
  • 20
  • Nope: duck typing. – Connor Low Feb 22 '22 at 23:28
  • 2
    You need to distinguish `ValueToKey` and `KeyToValue` structurally. You can do so at compiler and runtime with a symbol discriminant like [this](https://tsplay.dev/WJ4gvN), or a string discriminant like [this](https://tsplay.dev/w24rbm) or you can just pretend to do this like [this](https://tsplay.dev/NBjqzN). I could write any of these up as an answer although it looks like [@AlexWayne's answer](https://stackoverflow.com/a/71229825/2887218) pretty much covers it. – jcalz Feb 23 '22 at 00:10

3 Answers3

3

Branding can help here. It means you add a property that never really exists, except on the type, and use functions to cast values to the type with the correct brand.

Normally you might use a _brand: 'Something' prop, but in this case you want to support all string keys, so you can use a Symbol instead.

const brand = Symbol('KeysOrValuesBrand')

export interface KeyToValue {
    [key: string]: string;
    [brand]: 'KeyToValue'
}
export interface ValueToKey {
    [value: string]: string;
    [brand]: 'ValueToKey'
}

type StringToString = KeyToValue | ValueToKey

Now this works as you expect:

declare function foo(obj: KeyToValue): void
declare const bar: ValueToKey
foo(bar) // <-- THIS SHOULD throw an error

const valuesA: ValueToKey = bar // works
const valuesB: ValueToKey = inverse(bar) // error
const valuesC: ValueToKey = inverse(inverse(bar)) // works

The downside is that a function must make these objects for you, since that's how they get the brand. For example:

function makeValueToKey(input: Record<string, string>): ValueToKey {
    return input as ValueToKey
}

function makeKeyToValue(input: Record<string, string>): KeyToValue {
    return input as KeyToValue
}

declare function foo(obj: KeyToValue): void
foo({ a: 'b' }) // error
foo(makeValueToKey({ a: 'b' })) // error
foo(makeKeyToValue({ a: 'b' })) // works

Which may be kind of annoying.

Playground


That said, and this drifts into opinion, I believe that type branding is hack to hide a poor data model. If you can let the structural typing just work as intended, that's usually going be the better route.

It requires changing your data structures, but something like this is much simpler:

export interface KeyToValue {
    type: 'KeyToValue'
    data: Record<string, string>
}
export interface ValueToKey {
    type: 'ValueToKey'
    data: Record<string, string>
}

type StringToString = KeyToValue | ValueToKey

Playground

Alex Wayne
  • 178,991
  • 47
  • 309
  • 337
1

This is not possible in TypeScript.

TypeScript's uses what they call a Structural Typing system (conceptual subset of "Duck Typing"); it doesn't care what the name of a type is, only the shape. From the handbook:

The basic rule for TypeScript’s structural type system is that x is compatible with y if y has at least the same members as x.

All your interfaces have the same definition (disregarding the names of your indexes, which again don't matter in this type system), so they are all interchangeable within TypeScript.

Connor Low
  • 5,900
  • 3
  • 31
  • 52
  • This is true for the general concept, especially as applied in Python, but most TS docs refer to it as "[structural typing](https://www.typescriptlang.org/docs/handbook/type-compatibility.html)" compared to "nominal typing". – Jeff Bowman Feb 22 '22 at 23:40
  • Thanks, @JeffBowman; I always like to be as specific as possible, so I've updated my answer. – Connor Low Feb 22 '22 at 23:46
1

TypeScript has a structural type system, meaning that types are compared according to their content, not their names. That is,

export interface KeyToValue {
    [key: string]: string;
}
export interface ValueToKey {
    [value: string]: string;
}

declare identical types, because they only differ in naming, and you can freely interchange them:

const x: KeyToValue = {myKey: myValue};
const y: ValueToKey = x;

The only way for the compiler to see a difference among these types is if they differ in more than naming, for instance by using different types for keys and values. For instance, if the set of keys is known at compile type, you could use a union of literal types for the keys:

type Key = "red" | "green" | "blue";
type Value = string;

and then you could declare

type KeyToValue = Record<Key, Value>;
type ValueToKey = Record<Value, Key>;

and the compiler could distinguish between these types just fine.

Note that if the mapping is fixed a compile type, we can use keyof to easily derive these types from the values provided:

const colorMap = {
    red: 1,
    green: 2,
    blue: 3,
};

type Key = keyof typeof colorMap; // no need to repeat the possible keys again
meriton
  • 68,356
  • 14
  • 108
  • 175