1

I often use mapping objects as a more readable and salable alternative to switch, e.g. like this:

interface Foo {
    type: 'foo';
    foo: number;
}

interface Bar {
    type: 'bar';
    bar: string;
}

type FooBar = Foo | Bar;

const mapper: Record<FooBar['type'], () => string | number> = {
    foo: () => 1,
    bar: () => 'bar',
}

const foo = { type: 'foo', foo: 1 } satisfies Foo;
const bar = { type: 'bar', bar: '123' } satisfies Bar;

mapper[foo.type]() // -> 1
mapper[bar.type]() // -> 'bar'

This Record-based approach is fine in most places, but struggles when the object that is being mapped needs to be passed to the function:

// same Foo, Bar and FooBar types as before

const mapper: Record<FooBar['type'], (fooBar: FooBar) => string | number> = {
    // the function's parameters could and should be typed as Foo and Bar respectively
    // instead of FooBar in both cases
    foo: (fooBar) => (fooBar as Foo).foo,
    bar: (fooBar) => (fooBar as Bar).bar,
}

const foo = { type: 'foo', foo: 1 } satisfies Foo;
const bar = { type: 'bar', bar: '123' } satisfies Bar;

mapper[foo.type](foo) // -> 1
mapper[bar.type](bar) // -> 'bar'

// also works
[foo, bar].forEach(fooOrBar => {
    mapper[bar.type](fooOrBar)
})

This is possible using key remappings, like this. However, the forEach() breaks now:

// same Foo, Bar and FooBar types as before

type Mapper = {
    [E in FooBar as E['type']]: (obj: E) => number | string;
};

const mapper: Mapper = {
    foo: foo => foo.foo,
    bar: bar => bar.bar,
};

const foo = { type: 'foo', foo: 1 } satisfies Foo;
const bar = { type: 'bar', bar: 'bar' } satisfies Bar;

mapper[foo.type](foo); // -> 1
mapper[bar.type](bar); // -> 'bar'

// does not work any more, Foo & Bar will be reduced to never
[foo, bar].forEach(fooOrBar => {
    mapper[bar.type](fooOrBar)
})

Is it possible to make the forEach() from the last example work, while not giving up on the improved typing provided by the key remapping?

1
  • I think the important difference between your second and third example is that the third one doesn't use type casts. The second one would have the same problem as the third without the casts. Commented Jan 25, 2023 at 8:20

1 Answer 1

1

Quick Note

After writing this up, I realized maybe you meant to have mapper[fooOrBar.type] instead of mapper[bar.type] ?

Second Example

In the second example we can check the typing and can see the following:

(fooBar: FooBar) => string | number

One would expect with proper type guarding that mapper[bar.type] would not return a type of FooBar for the parameter, but instead a type of Bar. This is why it's not throwing errors because both items in the array are indeed FooBar.

const test = [foo, bar]
test.forEach(fooOrBar => {
  // const x: (fooBar: FooBar) => string | number
  const x = mapper[bar.type]
  x(fooOrBar)
})

Note: I had to assign the array to a variable otherwise I was getting a couple of errors on TS version 4.9.4

Left side of comma operator is unused and has no side effects.

Type '{ type: "bar"; bar: string; }' cannot be used as an index type.

Parameter 'fooOrBar' implicitly has an 'any' type, but a better type may be inferred from usage.

Third Example

Checking the typing we see:

(obj: Bar) => number | string

This time the type guard is working. Thus each item in the collection will no longer fulfill the type restraint since the callback with Foo will not fulfill the callback that you looked up that expects a Bar object for the parameter.

const test = [foo, bar]
test.forEach(fooOrBar => {
  // const x: (obj: Bar) => number | string
  const x = mapper[bar.type]
  x(fooOrBar)
})

This throws the error:

Argument of type '{ type: "foo"; foo: number; } | { type: "bar";
bar: string; }' is not assignable to parameter of type 'Bar'.  
Property 'bar' is missing in type '{ type: "foo"; foo: number; }' but
required in type 'Bar'.ts(2345) 

The Solution You Likely Don't Want

If we type guard we can get things running again, but it just feels like you're going full circle at this point making a lookup for your lookup or you hop into the evils of an if/switch statement 😆

It ultimately depends if you had a typo or not, and what you're aiming to accomplish.

const test = [foo, bar]
test.forEach(fooOrBar => {
  // const x: (obj: Bar) => number | string
  const x = mapper[bar.type]
  if (fooOrBar.type === 'bar') {
    x(fooOrBar)
  }
})

// Or something like this

test.filter((fooOrBar): fooOrBar is Bar => fooOrBar.type === 'bar').forEach(bar => {
  const x = mapper[bar.type]
  x(bar)
})

Type Guards

You could also get fancy and write your own type guard as well rather than the conditional check to perform the type guard.

https://www.typescriptlang.org/docs/handbook/advanced-types.html

After Thoughts...

Coincidentally today I was banging my head against the keyboard trying to do something similar in Dart. Sometimes it's best to step back and ask ourselves if this level of abstraction is worth it, or will our code be more concise and clear if we just keep things simple and use if statements. Sure the hash lookup should technically perform quicker, but if the reality is that you have a few conditions it's not like this optimization is a huge win, and adds many additional complexities.

If you are dealing with many entries and this abstraction seems worth it, then is there another route to go for the problem you're trying to solve? Should all the items implement some type of "callable" interface and conform to the same shape? Or if you need mixed/union types in a collection then you're going to have to find a way to deal with gating the types to identify them later when accessing them.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.