Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Assigning an object literal to a typescript generic type

I am building a FormConditions interface where I will have an object with arbitrary keys, each of which are an instance of a class implementing a Condition interface. I want to assign this object literal to a variable, and have the type of the resulting object A) only respond to the keys in the object literal, and B) respect any subclassing or other extending that each of those keys may have.

If you examine the code below, I have all of the actual types working just fine. The problem I'm running into is that I don't know how to assign the object to the variable directly without explicitly declaring the subtype of each key. Instead I can pass it through the identity function makeFormConditions which uses generics to correctly infer the type of the resulting object. Is this the only way to do this or is there a way to assign it directly? Feel free to alter the definition of FormCondition as you see fit to accomplish this.

interface Condition {
    name: string
    id: number
}

type FormConditions<T extends Record<string, Condition>> = {
    [P in keyof T]: T[P]
}

class SimpleCondition implements Condition {
    constructor(public name: string, public id: number) {}
}

class ListCondition<T> implements Condition {
    constructor(public name: string, public id: number, public entries: T[]) {}
}

// This is a passthrough function just to make the types work
function makeFormConditions<T extends Record<string, Condition>>(obj: T): FormConditions<T>  {
    return obj;
}

// Would prefer to avoid the function call to make types work
const conditions = makeFormConditions({
    simpleOne: new SimpleCondition('simpleOne', 1),
    simpleTwo: new SimpleCondition('simpleTwo', 2),
    list: new ListCondition('list', 3, ['foo', 'bar'])
})


// This works but is redundantly verbose
// const conditions : FormConditions<{
//     simpleOne: SimpleCondition;
//     simpleTwo: SimpleCondition;
//     list: ListCondition<string>;
// }> = {
//     simpleOne: new SimpleCondition('simpleOne', 1),
//     simpleTwo: new SimpleCondition('simpleTwo', 2),
//     list: new ListCondition('list', 3, ['foo', 'bar'])
// }
//
// would instead prefer to not use the function or be
// really specific about the type declaration:
// const conditions : FormConditions = {
//     simpleOne: new SimpleCondition('simpleOne', 1),
//     simpleTwo: new SimpleCondition('simpleTwo', 2),
//     list: new ListCondition('list', 3, ['foo', 'bar'])
// }

conditions.list.name
conditions.list.entries
conditions.simpleOne.name
conditions.simpleOne.entries // error, as expected

Here's a typescript playground link to the above.

like image 550
Wade Tandy Avatar asked Aug 29 '19 21:08

Wade Tandy


1 Answers

Short answer: No, you cannot assign an object literal containing heterogeneous types and maintain generic type constraints. A constrained helper function (as currently implemented) is required.

Expanding Interface Condition to include all Sub-type Properties

The definition of Condition could be expanded to accept an optional entries array, such that FormConditions<E, T extends Record<string, Condition<E>>> could hold both SimpleConditions and ListConditions. This has the undesired side-effect that instances of SimpleCondition may reference the missing entries property without error.

interface Condition<E> {
    name: string
    id: number
    entries?: E[]
}

type FormConditions<E, T extends Record<string, Condition<E>>> = {
    [P in keyof T]: T[P]
}

class SimpleCondition<E = never> implements Condition<E> {
    constructor(public name: string, public id: number) {}
}

class ListCondition<E> implements Condition<E> {
    constructor(public name: string, public id: number, public entries: E[]) {}
}

const conditions: FormConditions<string, Record<string, Condition<string>>> = {
    simpleOne: new SimpleCondition('simpleOne', 1),
    simpleTwo: new SimpleCondition('simpleTwo', 2),
    list: new ListCondition('list', 3, ['foo', 'bar'])
}

conditions.list.name;
conditions.list.entries;
conditions.simpleOne.name;
conditions.simpleOne.entries;  // Expected error; however, no error, since `entries` is optional parameter.

Limit Interface Condition to include only name and id

Since Condition is limited, there is an error (as expected) when attempting to access entries on instances of SimpleCondition. However, in the context of type FormConditions<E, T extends Record<string, Condition>>, instances of ListCondition result in an error when referring to entries, since the type has been narrowed to Condition.

interface Condition {
    name: string
    id: number
}

type FormConditions<E, T extends Record<string, Condition>> = {
    [P in keyof T]: T[P]
}

class SimpleCondition<E = never> implements Condition {
    constructor(public name: string, public id: number) {}
}

class ListCondition<E> implements Condition {
    constructor(public name: string, public id: number, public entries: E[]) {}
}

const conditions: FormConditions<string, Record<string, Condition>> = {
    simpleOne: new SimpleCondition('simpleOne', 1),
    simpleTwo: new SimpleCondition('simpleTwo', 2),
    list: new ListCondition('list', 3, ['foo', 'bar'])
}

conditions.list.name;
conditions.list.entries; // Error: Property 'entries' does not exist on type 'Condition'.
conditions.simpleOne.name;
conditions.simpleOne.entries; // error (as expected - Good)
like image 56
Christopher Peisert Avatar answered Oct 24 '22 14:10

Christopher Peisert