3

How can I map values of object keys to the same object in TypeScript in a way that IntelliSense will know work as shown in the example below?

const obj = getByName([
  { __name: 'foo', baz: 'foobar' },
  { __name: 'bar', qux: 'quux' },
]);

obj.foo.__name // ok
obj.foo.baz // ok
obj.foo.quuz // not ok

obj.bar.__name // ok
obj.bar.qux // ok
obj.bar.quuz // not ok

3 Answers 3

3

You could go with the following:

export function getByName<
  TName extends string,
  TObj extends {__name: TName},
>(arr: TObj[]): Record<TName, TObj> {
  return arr.reduce((acc, obj) => {
    return {
      ...acc,
      [obj.__name]: obj,
    };
  }, {} as Partial<Record<TName, TObj>>) as Record<TName, TObj>;
}

typescript playground

EDIT: The solution above has two issues:

  1. The resulting object has keys of any string (meaning obj.nonExistingKey.qux will pass)
  2. It considers obj.foo.qux to be okay despite there is no {__name: 'foo', qux: 'value'} in the provided array.

To resolve the issue no1, we can pass the array as const:

function getByName<TObj extends {__name: string}>(arr: readonly TObj[]) {
  return arr.reduce((acc, obj) => {
    return {
      ...acc,
      [obj.__name]: obj,
    };
  }, {}) as Record<TObj['__name'], TObj>;
}

const obj = getByName([
  { __name: 'foo', baz: 'foobar' },
  { __name: 'bar', qux: 'quux' },
] as const);

obj.nonExistingKey.quuz // not ok NOW!

typescript playground

However, I am not aware of a solution for the issue no2 with generics. If it is really important to you, you can implement some type guards.

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

4 Comments

This is a tricky one because OP's objects aren't all the same type. I'm wondering if it's possible to maintain the relationship between keys and values so that obj.foo.qux wouldn't be ok while obj.bar.qux would be ok. But that is really difficult, maybe impossible?
@LindaPaiste That is actually a really good point! I can't think of any solution at the moment. But I also have to update the answer to not allow obj.notExistingKey.baz
Hey! Just came back to add precisely what @LindaPaiste brought up. Been banging my head for a solution but I don't think there's for TypeScript to infer everything without generics as of now, so I'll likely have to create an interface for the output objects. Will keep trying and update if anything.
I just posted a solution which solves problem #2, but it only works by using as const so it's not really ideal.
2

The part where this gets tricky is trying to maintain the relationship between the property names and the specific interface for that property. Ideally, property baz would exists on the key foo but not on key bar.

I've got it semi-working, but it only works if you use __name: 'foo' as const to say that the type of this name is the literal string 'foo' only. Otherwise typescript sees each name's type as string and the association between specific names and specific properties is lost.

// standard util to get element type of an array 
type Unpack<T> = T extends (infer U)[] ? U : never;

type KeyedByName<U extends {__name: string}[]> = {
    [K in Unpack<U>['__name']]: Extract<Unpack<U>, {__name: K}>
}

In KeyedByName we say that the value for a key is can only be the elements of the array whose __name property matches the type of that key. But if the key type is just string this won't narrow at all.

If we use the 'foo' as const notation, the return type KeyedByName becomes highly specific.

const inputsConst = [
  { __name: 'foo' as const, baz: 'foobar' },
  { __name: 'bar' as const, qux: 'quux' },
];

type K1 = KeyedByName<typeof inputsConst>

Evaluates to

type K1 = {
    foo: {
        __name: "foo";
        baz: string;
        qux?: undefined;
    };
    bar: {
        __name: "bar";
        qux: string;
        baz?: undefined;
    };
}

We know that certain properties are required and that others don't exist (can only be undefined).

const checkK1 = ( obj: K1 ) => {

    const fooName: string = obj.foo.__name // ok
    const fooBaz: string = obj.foo.baz // required to be string
    const fooQux: undefined = obj.foo.qux // can access, but will always be undefined because it doesn't exist
    const fooQuuz = obj.foo.quuz // error

    const barName: string = obj.bar.__name // ok
    const barQux: string = obj.bar.qux // required to be string
    const barBaz: undefined = obj.bar.baz // can access, but will always be undefined because it doesn't exist
    const barQuuz = obj.bar.quuz // error
}

However without using foo as const this type is not any more specific than the Record in @gurisko's answer because typescript sees the type of 'foo' and 'bar' both as string and therefore they are equivalent.

const inputsPlain = [
    { __name: 'foo', baz: 'foobar' },
    { __name: 'bar', qux: 'quux' },
];

type K2 = KeyedByName<typeof inputsPlain>

Evaluates to:

type K2 = {
    [x: string]: {
        __name: string;
        baz: string;
        qux?: undefined;
    } | {
        __name: string;
        qux: string;
        baz?: undefined;
    };
}

All properties are seen as optional regardless of whether they are from foo or bar.

const checkK2 = ( obj: K2 ) => {

    const fooName: string = obj.foo.__name // ok
    const fooBaz: string | undefined = obj.foo.baz // ok but could be undefined
    const fooQux: string | undefined = obj.foo.qux // ok but could be undefined
    const fooQuuz = obj.foo.quuz // error

    const barName: string = obj.bar.__name // ok
    const barQux: string | undefined = obj.bar.qux // ok but could be undefined
    const barBaz: string | undefined = obj.bar.baz // ok but could be undefined
    const barQuuz = obj.bar.quuz // error
}

Playground Link

1 Comment

Hey @Linda Paiste, thank you so much! I used your solution to come up with another one, literally the only difference is that instead of objects I'm using classes. IntelliSense seems to be working as desired. I had to create a new answer because the URL link is too long, could you let me know your thoughts?
1

The following answer is heavily based on @Linda Paiste's answer.

The difference is that instead of using plain objects, I'm using classes as substitutes. Quoting Linda:

In KeyedByName we say that the value for a key is can only be the elements of the array whose __name property matches the type of that key. But if the key type is just string this won't narrow at all.

By using classes I believe TypeScript goes through a much more effective way of inferring the types when unpacking them, since it seems to type-check the objects using the __types properties mapped to the classes themselves. Instead of combining the plain objects into one, it effectively infers the correct type, resulting in KeyedByName to resolve the following:

class Foo {
  get __name(): 'foo' { return 'foo'; }

  constructor(public baz: string) {}
}

class Bar {
  public readonly __name: 'bar' = 'bar';

  constructor(public qux: string) {}
}

type ObjectsByName = KeyedByName<typeof inputs>;

Where ObjectsByName evaluates to:

type ObjectsByName = {
    foo: Foo;
    bar: Bar;
}

Playground Link

6 Comments

Glad I could help :) Of course your class stubs are giving all sorts of errors. Here's a stub that works (but requires a constructor argument). class Foo { public static __name: 'foo' = 'foo'; constructor(public baz: string){} } We say that __name is static aka it's the same for every Foo object and then we both declare that it must be the string 'foo' and set it to that value.
Oops! Sorry I didn't even realize! Having __name as a static member was throwing Type '(Foo | Bar)[]' does not satisfy the constraint '{ __name: string; }[]'. Type 'Foo | Bar' is not assignable to type '{ __name: string; }'. Property '__name' is missing in type 'Foo' but required in type '{ __name: string; }'., so I had to make use of both the static member and a public member. Just updated the answer. It's not quite where I'd like it to be, but getting close.
PS: I'll leave the question as unanswered for a while, maybe someone can come up with a better solution or improve this one. On that note, do you want to update your answer with these changes or similar? I will accept it as the answer if so.
Hmm I forgot that static would create issues with TS. You could do public static readonly __name: 'foo' = 'foo' or get __name(): 'foo' { return 'foo'; } those would both make it immutable.
I meant public readonly __name: 'foo' = 'foo' without the static.
|

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.