colxi
colxi

Reputation: 8730

Typescript : type inference for Objects inside an array

I've been struggling for several days trying to accomplish some Typescript proper inference in order to achieve type validation on the data structure provided to one of my Class constructors.

Essentially my constructor receives an array containing a list of objects that contain a declaration of a (sort of) plugin and a "custom configuration" for the plugin, each one.

I need Typescript to ensure the provided customConfig matches the types on the defaultConfig, however, I had no luck, not even getting close to it.

The several attempts I've made become really messy and nonsensical, so I will attach a simple representation of the code that I hope helps to represent the idea :

I hope somebody can provide some light


type Entry = {
    extension: {
        defaultConfig: Record<PropertyKey, unknown>
        install: any
        uninstall: any
    },
    customConfig: Record<PropertyKey, unknown>
}


function initExtensions<I extends Entry[]>(a: I): void { /* ... */ }


initExtensions([
    {
        extension: {
            defaultConfig: { foo: true },
            install: () => {/* ... */ },
            uninstall: () => {/* ... */ },
        },
        customConfig: { foo: true } // <-- SHOULD BE OK
    },
    {
        extension: {
            defaultConfig: { bar: 123 },
            install: () => {/* ... */ },
            uninstall: () => {/* ... */ },
        },
        customConfig: { bar: true }  // <-- Should complain as should be a NUMBER
    },
])

Upvotes: 1

Views: 504

Answers (3)

artur grzesiak
artur grzesiak

Reputation: 20348

I hope, the below solution achieves almost what you are looking for.

// keeping defaultConfig and customConfig as seperate generics makes TS inference more granular
type EntryRaw<C extends {}, U extends {}> = {
    extension: {
        defaultConfig: C
        install: any
        uninstall: any
    },
    customConfig: U
}

// checks if configs are equal types wise
type ValidEntry<E extends EntryRaw<{}, {}>> = E extends EntryRaw<infer C, infer U> ? C extends U ? U extends C ? E : never : never : never


type ValidEntries<ES extends EntryRaw<{}, {}>[]> =
    ES extends [] ? ES : // recursion condition
    ES extends [infer E, ...infer R] ? // destruct
    E extends EntryRaw<{}, {}> ? // auxiliary check to allow ValidEntry check
    R extends EntryRaw<{}, {}>[] ? // auxiliary check to allow recursive 'call'
    E extends ValidEntry<E> ?
        [E, ...ValidEntries<R>] : // entry ok, recursive 'call'
        // some hacky error reporting
        [{ __INCOMPATABLE_CONFIG__: [E['extension']['defaultConfig'], 'vs', E['customConfig']] } & Omit<E, 'customConfig'>, ...ValidEntries<R>] 
            : never  : never  : never


// I have been not able to make TS happy with single array argument 
function initExtensions<ES extends EntryRaw<{}, {}>[]>(...es: ValidEntries<ES>): void {  
 }

initExtensions(
    {
        extension: {
            defaultConfig: { foo: true },
            install: () => {/* ... */ },
            uninstall: () => {/* ... */ },
        },
        customConfig: { foo: true } // <-- SHOULD BE OK
    },
    {
        extension: {
            defaultConfig: { bar: 123 },
            install: () => {/* ... */ },
            uninstall: () => {/* ... */ },
        },
        customConfig: { bar: true }  // <-- Should complain as should be a NUMBER
    },
)

PLAYGROUND

Upvotes: 5

soimon
soimon

Reputation: 2580

The method Todd Skelton demonstrates in his answer is the only boilerplate-free solution I know of as well. The problem is that every entry in your input array is of a different type (although extending Entry), that needs to be distinctly represented in your generic function signature in order to be correctly type checked. Since there is currently no way to specify a function with a n number of template parameters, you'll end up with abominations like this:

type Entry<C> = {
    extension: { defaultConfig: C };
    customConfig: Partial<C>;
    install: any;
    uninstall: any;
};

type E<C> = Entry<C>;
function initExtensions<C1,C2,C3,C4,C5>(entries: [E<C1>,E<C2>,E<C3>,E<C4>,E<C5>]): void; 
function initExtensions<C1,C2,C3,C4>(entries: [E<C1>,E<C2>,E<C3>,E<C4>]): void; 
function initExtensions<C1,C2,C3>(entries: [E<C1>,E<C2>,E<C3>]): void; 
function initExtensions<C1,C2>(entries: [E<C1>,E<C2>]): void; 
function initExtensions<C1>(entries: [E<C1>]):void; 
function initExtensions(entries:E<any>[]): void {
    // Initialize your extensions here
}

Which in fact works the way you wish up to 5 entries, by representing the input not as an array of one type but as a tuple of different lengths filled with unique types. This might look ugly and weird, but this is in fact how libraries like lodash type their variadic functions so it's a bit of a necessary evil.

There is nothing stopping you from generating up to 50 variances of this signature of course: it's there for type checking only and is not reflected in your final output. Either that, or pass the configuration objects one by one:

function initExtension<C>(entry: Entry<C>): void {
    // Initialize one extension here
}

initExtension({
    extension: { defaultConfig: { foo: true } },
    customConfig: { foo: true },
    install: () => null,
    uninstall: () => null,
});

Which is a mere alternative to Todds approach.

Upvotes: 1

Todd Skelton
Todd Skelton

Reputation: 7239

You can't really do exactly what you are looking for because TypeScript doesn't support existential types. https://github.com/Microsoft/TypeScript/issues/14466

However, you can create a small wrapper around each of your entries if you just want to validate that it's correct. The wrapper will allow TypeScript to infer the type on each entry in your

type Entry<T, C extends T> = {
    extension: {
        defaultConfig: T
        install: any
        uninstall: any
    },
    customConfig: C
}

function asEntry<T, C extends T>(entry: Entry<T, C>) { return entry };

function initExtensions(entries: Entry<any, any>[]): void { /* ... */ }

initExtensions([
    asEntry({
        extension: {
            defaultConfig: { foo: true },
            install: () => {/* ... */ },
            uninstall: () => {/* ... */ },
        },
        customConfig: { foo: true } // <-- OK
    }),
    asEntry({
        extension: {
            defaultConfig: { bar: 123 },
            install: () => {/* ... */ },
            uninstall: () => {/* ... */ },
        },
        customConfig: { bar: true }  // <-- ERROR
    })
])

Upvotes: 1

Related Questions