3

I want to access the value of a nested object in typescript using a generic function.

(TL;DR: version with link to playground at the end of the post).

Setup

Following setup. I want to access icons by their ligatures (CSS). Also I want to give all icons a symbolic name, so the ligature and the symbolic name of the same icon can differ, for example:

symbolic name: 'home'
ligature     : 'icon-home'

Also it is possible to add custom icon fonts to extend the icon collection. Therefore the typescript have to be edited. To prevent name collisions and to make this expansion possible, I defined a namespace for icons called icon-source.

For example: Calling engine with icon-source="car" returns engine, calling engine with icon-source="airplaine" returns turbine.

My approach

So let's assume there are 3 defined icon sets, where icon-set="standard" is the default case.

At first I define an enum (string) which includes all available icon-sets.

const enum GlobalIconSources {
    'standard' = 'standard',
    'airplaine' = 'airplaine',
    'car' = 'car',
}

Then I define for each icon-set another enum (string) and a corresponding type. The keys of the type are restricted to the strings of the enumeration.

const enum GlobalIconSourcesStandard {
    'none' = 'none',
    'home' = 'home',
    'power' = 'power',
};

type ListOfStandardIcons = {
    [key in keyof typeof GlobalIconSourcesStandard]: string
}

After that I defined an interface and a corresponding global object that include all icons.

/**
 * Defines interface which includes all icon sources with their types.
 */

interface GlobalIcons {
    [GlobalIconSources.standard]: ListOfStandardIcons,
    [GlobalIconSources.airplaine]: ListOfSource1Icons,
    [GlobalIconSources.car]: ListOfSource2Icons,
}

/**
 * Defines global object in which all used icon names are defined.
 * [symbolic-name] : [css-name/ligature]
 */

const ICONS : GlobalIcons = {
    'standard': {
        'none': '',
        'home': 'icon-home',
        'power': 'icon-power'
    },
    'airplaine': {
        'wing': 'ap-wing',
        'turbine': 'ap-turbine',
        'landing-gear': 'ap-lg',
    },
    'car': {
        'brakes': 'car-brakes',
        'engine': 'car-engine',
        'car-tire': 'car-tire',
    },
};

Function for accessing values

Then there is the following function to access the values of the global object. The function should return the value (ligature) of the icon if the icon / icon-source combination exists. Otherwise the function returns undefined.

/**
 * Returns the ligature of an icon.
 * @param {map} Global icon object.
 * @param {test} Symbolic icon name.
 * @param {source} Source, where icon is defined.
 * @returns Icon ligature when icon is defined, otherwise undefined.
 */
function getIcon<T extends GlobalIcons, K extends keyof GlobalIcons, S extends keyof T[K]>(map: T, test: unknown, source: Partial<keyof typeof GlobalIconSources> = GlobalIconSources.standard) : T[K] | undefined{
    if(typeof test === 'string' && typeof source === 'string' && typeof map === 'object'){
        if(map.hasOwnProperty(source)){
            const subMap =  map[source as K];
            if(subMap.hasOwnProperty(test)) return subMap[test as S];
        }
    }
    return undefined;
}

This works but: The returned type of the function is ListOfStandardIcons | ListOfSource1Icons | ListOfSource2Icons | undefined (see ts playground). I expected string as returned type.

Let's assume I call the function with source="standard" and test=""home. Then the generics should be:

T       : GlobalIcons
T[K]    : ListOfStandardIcons | ListOfSource1Icons | ListOfSource2Icons (assuming K is keyof T)
T[K][S] : string (assuming K is keyof T and S is keyof T[K]

I know that I am returning T[K] | undefined. I wanted to return T[K][S] | undefined but then the returned type is always undefined (according to TS playground).

Anyone an idea how I can handle this function that the returned type is the correct type of the subobject (ListOfStandardIcons | ListOfSource1Icons | ListOfSource2Icons)?

TS playground

TypeScript Playground Demo

Edit: Setup changed

I changed the setup and removed the enums and using objects now.

// Defines all available icon sources 
const iconSources = {
    'standard': 'standard',
    'anotherSource': 'anotherSource',
} as const;

// List of icon sources corresponding to the iconSource object
type IconSource = Partial<keyof typeof iconSources>;

// Defines list icon of the "standard" icon source
const StandardIcons = {
    'none': '',
    'home': 'icon-home',
    'power': 'icon-power',
} as const;

// Defines list icon of the "anotherSource" icon source
const Source1Icons = {
    'car': 'my-car',
    'airplaine': 'my-airplaine',
} as const;

// Defines interface and global object
interface Icons {
    [iconSources.standard]: { [key in keyof typeof StandardIcons]: string },
    [iconSources.anotherSource]: { [key in keyof typeof Source1Icons]: string },
}

// Access icon ligatures using icons[iconSourceName][iconKey]
const icons: Icons = {
    'standard': StandardIcons,
    'anotherSource': Source1Icons,
};

Also I changed the syntax for accessing the icon source. Now I want to pass 1 parameter which is "iconSource/iconName". When the string contains no /, the standard icon source is used. So instead of 2 parameter, 1 is needed now but this test parameter needs to have to type unknown because it is a user input which was not validated so far.

/**
 * This function was copied from here: https://fettblog.eu/typescript-hasownproperty/
 */
function hasOwnProperty<X extends {}, Y extends PropertyKey>
  (obj: X, prop: Y): obj is X & Record<Y, unknown> {
  return obj.hasOwnProperty(prop)
}

function getIcon<L extends Icons, Source extends keyof L, Icon extends keyof L[Source]>(list: L, test: unknown): L[Source][Icon] | undefined {
    if(typeof test === 'string'){
        let icon: string = test;
        let source: string = iconSources.standard; // Use the standard icon source, when no source is defined
        if(test.indexOf('/') > -1){
            const splitted = test.split('/');
            source = splitted[0];
            icon = splitted[1];
        }
        // If source is correct
        if(hasOwnProperty(list, source)){
            // If icon is correct return list[source][icon]
            if(hasOwnProperty(list[source as Source], icon)) return list[source as Source][icon as Icon];
        }
    }
    return undefined;
}

But i ran in the same problem that the function do always return the type undefined (the returned values are correct).

// Test
const input1: unknown = 'home';
const input2: unknown = 'standard/none';
const input3: unknown = 'anotherSource/car';
const input4: unknown = 'abc';
const input5: unknown = 'anotherSource/abc';

// Expected results but type of all variables is undefined
const result1 = getIcon(icons, input1); // => 'icon-home' with typeof string
const result2 = getIcon(icons, input2); // => '' with typeof string
const result3 = getIcon(icons, input3); // => 'my-car' with typeof string
const result4 = getIcon(icons, input4); // => undefined 
const result5 = getIcon(icons, input5); // => undefined 

New playground

New Playground

7
  • 1
    Is there a reason you want to use a string enum instead of just strings? It's fairly annoying to have the compiler try to covert from a string into the equivalent enum value, since one of the points of enums is to remove reliance on such values in your code (e.g., users should always write GlobalIconSources.standard and never "standard" in code). If I keep enums then this might be what you want. If I remove enums then it simplifies to this. If either of those meet your needs I can write up an answer. Commented Sep 14, 2021 at 18:23
  • Maybe I've to check if string enums are still the right thing to go. In the first attempt I wanted to use string enums to access flat objects using Object.values(enum). But without a string enum, there would be also the index returned, but I need to prevent Object.values(enum).includes(someVarDefinedWhileRuntime : unknown) == true when someVarDefinedWhileRuntime could be an index (0, 1, ... ). Commented Sep 14, 2021 at 18:38
  • You can still use Object.values() with the code I changed it to. Please test that code against your use cases and get back to me if it works or does not work for you. Commented Sep 14, 2021 at 18:42
  • I updated my post, changed the setup and tried it again but ran into the same problem, that the returned type of the function is undefined. Added new playground at the bottom of the post. Maybe you could have a look at it. Tried to use your solution and transfered it to my new setup but my attempt failed. Commented Sep 15, 2021 at 18:38
  • That's quite a scope change. Does this meet your needs? If so, I'll write up an answer. If not, I'm willing to look more but not if the scope widens significantly again. Commented Sep 15, 2021 at 19:24

2 Answers 2

1

My inclination here would be to declare getIcon() as an overloaded function with multiple call signatures corresponding to the multiple ways of calling the function. Here are the call signatures:

// call signature #1
function getIcon<S extends keyof Icons, I extends string & keyof Icons[S]>(
  list: Icons, test: `${S}/${I}`): Icons[S][I];

The first call signature will be invoked if you pass in a test parameter containing a slash (/) character, where the part before the slash is a key of Icons (from which the compiler infers the type parameter S), and the part after the slash is a key of Icons[S] (from which the compiler infers the type parameter I). This is possible due to inference from template literal type introduces in TypeScript 4.1. Note that we need to constrain I to string also in order for the compiler to be happy with including I in a template literal type. The return type of this call is Icons[S][I].

// call signature #2
function getIcon<I extends keyof Icons['standard']>(
  list: Icons, test: I): Icons['standard'][I];

The second call signature will be invoked if the first call signature is not, and if you pass in a test parameter which is a key of Icons['standard'], from which the compiler infers the type parameter I. It returns Icons['standard'][I]. This call signature behaves like the first call signature with the S parameter already specified as "standard".

// call signature #3, optional
function getIcon(list: Icons, test: string): undefined;

The last call signature is invoked if the first two are not, and it accepts any string value for test, and returns undefined. It's a default behavior that happen when the compiler falls tries to match call signatures but falls through. This is technically what you asked for, but I think it might be better not to include this call signature.

If you comment it out, then when someone calls getIcon(icons, "someRandomCrazyString"), instead of allowing it and returning undefined, the compiler will warn you that you're calling getIcon() wrong. That's part of the appeal of a static type system; catching such undesirable code before it has a chance to be executed at runtime.


Anyway, once you define those call signatures, you can implement the function. The following implementation is pretty much the same as yours except it doesn't bother to do much runtime checking. If the people calling getIcon() are writing TypeScript, then they will be warned if, for example, they pass in a non-string value for test like getIcon(icons, 123). The only reason to do runtime checking here is if you are concerned that someone will run getIcon() from JavaScript code that has not been typechecked. It's up to you:

// implementation
function getIcon(list: any, test: string) {
    let source: string = iconSources.standard
    let icon: string = test;
    if (test.indexOf('/') > -1) {
        const splitted = test.split('/');
        source = splitted[0];
        icon = splitted[1];
    }
    return list[source]?.[icon];
}

So, let's test it out. Note that you will be happiest if you do not annotate the types of icons and input1, etc. Type annotations (of non-union types) tend to make the compiler forget about any actual value passed in:

const icons = {
    'standard': StandardIcons,
    'anotherSource': Source1Icons,
};
const input1 = 'home';
const input2 = 'standard/none';
const input3 = 'anotherSource/car';
const input4 = 'abc';
const input5 = 'anotherSource/abc';

Here the compiler knows that input1 is of the string literal type "home" instead of string or unknown. If you annotate as string or unknown, the compiler will not know what getIcon() will return, or not let you call getIcon().

Okay, now for the test. This is what you get if you call getIcon() with three call signatures:

const result1 = getIcon(icons, input1); // const result1: string
const result2 = getIcon(icons, input2); // const result2: string
const result3 = getIcon(icons, input3); // const result3: string
const result4 = getIcon(icons, input4); // const result4: undefined
const result5 = getIcon(icons, input5); // const result5: undefined

If you comment out the third one, then this is what you get:

const result1 = getIcon(icons, input1); // const result1: string
const result2 = getIcon(icons, input2); // const result2: string
const result3 = getIcon(icons, input3); // const result3: string
const result4 = getIcon(icons, input4); // compiler error!
const result5 = getIcon(icons, input5); // compiler error!

In both cases the compiler recognizes that input1, input2, and input3 are valid inputs. In the former case, input4 and input5 are accepted and undefined is returned, and in the latter case, input4 and input5 are underlined with red squigglies and you are warned that no overload of getIcon() matches that call.

Playground link to code

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

Comments

1

All you need to do is to overload your function:

console.clear();

/**
 * Defines all available icon sources.
 */

const enum GlobalIconSources {
    'standard' = 'standard',
    'airplaine' = 'airplaine',
    'car' = 'car',
}

/**
 * Defines standard icons with corresponding type.
 */

const enum GlobalIconSourcesStandard {
    'none' = 'none',
    'home' = 'home',
    'power' = 'power',
};

type ListOfStandardIcons = {
    [key in keyof typeof GlobalIconSourcesStandard]: string
}

/**
 * Defines custom icons with corresponding type.
 */

const enum GlobalIconSourcesSource1 {
    'wing' = 'wing',
    'turbine' = 'turbine',
    'landing-gear' = 'landing',
};

type ListOfSource1Icons = {
    [key in keyof typeof GlobalIconSourcesSource1]: string
}

/**
 * Defines more custom icons with corresponding type.
 */

const enum GlobalIconSourcesSource2 {
    'brakes' = 'brakes',
    'engine' = 'engine',
    'car-tire' = 'car-tire',
};

type ListOfSource2Icons = {
    [key in keyof typeof GlobalIconSourcesSource2]: string
}

/**
 * Defines interface which includes all icon sources with their types.
 */

interface GlobalIcons {
    [GlobalIconSources.standard]: ListOfStandardIcons,
    [GlobalIconSources.airplaine]: ListOfSource1Icons,
    [GlobalIconSources.car]: ListOfSource2Icons,
}

/**
 * Defines global object in which all used icon names are defined.
 * [symbolic-name] : [css-name/ligature]
 */

const ICONS = {
    'standard': {
        'none': '',
        'home': 'icon-home',
        'power': 'icon-power'
    },
    'airplaine': {
        'wing': 'ap-wing',
        'turbine': 'ap-turbine',
        'landing-gear': 'ap-lg',
    },
    'car': {
        'brakes': 'car-brakes',
        'engine': 'car-engine',
        'car-tire': 'car-tire',
    },
} as const;


const hasProperty = <Obj, Prop extends string>(obj: Obj, prop: Prop)
    : obj is Obj & Record<Prop, unknown> =>
    Object.prototype.hasOwnProperty.call(obj, prop);

function getIcon<
    IconMap extends GlobalIcons,
    Source extends keyof IconMap,
    Test extends keyof IconMap[Source],
    >(map: IconMap, test: Test, source: Source): IconMap[Source][Test]
function getIcon<
    IconMap extends GlobalIcons,
    Source extends keyof IconMap,
    Test extends keyof IconMap[Source],
    >(map: IconMap, test: Test, source: Source) {
    if (typeof test === 'string' && typeof source === 'string' && typeof map === 'object') {
        if (hasProperty(map, source)) {
            return map[source][test]
        }
    }

    return undefined;
}

// Test
const a = getIcon(ICONS, 'home', 'standard');  // => "icon-home"
const b = getIcon(ICONS, 'turbine', 'airplaine'); // => ap-turbine
const c = getIcon(ICONS, 'engine', 'car');     // => 'car-engine'

console.log(a);
console.log(b);
console.log(c);

Playground

I have used as const for ICONS to infer the whole object. IF you are not allowed to use as const you need to pass literal object as an argument instead of reference.

Btw, you might don't want to use hasProperty at all, because only literal arguments are allowed.

P.S. you can find more about function arguments inference in my blog

2 Comments

Unfortunately I cannot use as const because I need the object during runtime because of a lot of unknown user inputs. I will have a look at your blog. I did not really get the overload functions and the concept of dealing with nested stuff in TS, yet. The examples in the TS doc are really simple and easy to understand but if I come up with more complex stuff, I have to google a lot (at least for the correct syntax).
I believe @jcalz will provide a better answer. I did not know that you are allowed to use string types instead of enums. Every use case has own requirements. Unfortunatelly I dont have enough time to provide an update. Please wait fow jcalz's answer

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.