Home > Software design >  How to type a record of generic interfaces with related constraints?
How to type a record of generic interfaces with related constraints?

Time:10-10

I'm using a parser toolkit (Chevrotain) to write a query language, and would like to allow users to expand its functionality. I have all the pieces I need to do this, but am struggling with defining types for this expansion behavior. I'd like to be able to type the config object such that users using Typescript will have convenient IDE assistance asserting that their input is correct; it seems possible (or very close to possible), so I've been trying to write the types (rather than assert at runtime).

A (trivial) example of some config:

ops: {
  equal: {
    lhs: {
      type: 'string',
      from: v => String(v),
    },
    rhs: {
      type: 'number',
      from: v => v.toString(),
    },
    compare: (lhs, rhs) => lhs === rhs,
  }
  equal: { /*...*/ }
}

I'd like the following things to be true:

  1. The type of the argument to from is related to the string literal value of the type property. I've managed to accomplish this a few ways, the cleanest of which is a simple type such as:
type ArgTypes = {
  string: string,
  number: number,
  ref: any, // the strings don't have to be typescript types, and the types could be more complex
}
  1. The lhs and rhs fields may receive different types than each other, and produce different types than each other.

  2. The compare function takes as input the output of the lhs and rhs properties and returns a boolean.

I've been able to type things at the level of a single operator (equal), but I haven't been able to extend this into an object-bag of operators. Here's one Playground link where I tried to build it up a piece at a time, using generics and child types: attempt N. In this attempt, I can't seem to hold on to the narrow types once I get to the object-map bit; it might not be possible to have a type signature for Ops at line 105 that works?

And another (inspired by Preventing object literals type widening when passed as argument in TypeScript) where I tried to do it all at once, just adding type arguments for every damn thing: attempt N 1. This almost works, but the moment you uncomment the "compare" line in the type signature, the (previously-working) narrow types become general. (e.g. the literal "number" becomes string)

Is it possible to do this or should I give up? If so, how?

CodePudding user response:

The underlying problem here is that TypeScript has a limited ability to simultaneously infer generic type parameters and contextually type function parameters. The inference algorithm is a set of reasonable heuristics that works in a lot of common use cases, but it is not a full unification algorithm guaranteed to assign the proper types to all generic type arguments and all unannotated values, as proposed (but not yet or possibly ever implemented) in microsoft/TypeScript#30134.

So generic type parameters can be inferred:

declare function foo<T>(x: (n: number) => T): T
foo((n: number) => ({ a: n })) // T inferred as {a: number}

and unannotated function parameters can be inferred:

declare function bar(f: (x: { a: number }) => void): void;
bar(x => x.a.toFixed(1)) // x inferred as {a: number}

and there is some ability to do both at once, especially if you are asking to infer from multiple function arguments and the flow of inference goes from left to right:

declare function baz<T>(x: (n: number) => T, f: (x: T) => void): void;
baz((n) => ({ a: n }), x => x.a.toFixed(1))
// n inferred as number, T inferred as {a: number}, x inferred as {a: number}

But there are cases where this doesn't work. Before TypeScript 4.7, the following variant where you have a single function argument would fail to infer as desired:

declare function qux<T>(arg: { x: (n: number) => T, f: (x: T) => void }): void;
qux({ x: (n) => ({ a: n }), f: x => x.a.toFixed(1) })
// TS 4.6, n inferred as number, T failed to infer, x failed to infer
// TS 4.7, n inferred as number, T inferred as {a: number}, x inferred as {a: number}

This was fixed in TypeScript 4.7 with microsoft/TypeScript#48538. But it's still far from a perfect algorithm.

For example, this sort of inference breaks down when combined with inference from mapped types. Simple inference from a mapped type looks like this:

declare function frm<T>(obj: { [K in keyof T]: (n: number) => T[K] }): void;
frm({ p: n => ({ a: n }) });
// T inferred as {p: {a: number}}

But try to combine that with simultaneous contextual inference of function parameters, and it fails:

declare function doa<T>(
    obj: { [K in keyof T]: { x: (n: number) => T[K], f: (x: T[K]) => void } }
): void;
doa({ p: { x: (n) => ({ a: n }), f: x => x.a.toFixed(1) } });
// TS 4.8, T failed to infer
doa<{ p: { a: number } }>(
  { p: { x: (n) => ({ a: n }), f: x => x.a.toFixed(1) } }); // okay

So, unfortunately, if the relationship you're trying to express with your types involves a lot of complicated inference paths involving contextual typing, it's probably going to fail.


Your example code is trying to do inference from mapped types and callback parameter inference at the same time, so it fails. Well, a call signature like

function ops<T extends { [key: string]: any }, O extends Ops<T>>(spec: O): O {
    return spec;
}

would never work, because generic constraints do not serve as inference sites; see microsoft/TypeScript#7234. The compiler cannot infer T from the fact that O extends Ops<T>. We need to change it to something like

function ops<T>(spec: Ops<T>): Ops<T> { // infer from mapped type
    return spec;
}

then we can get inference... although he error messages are a bit weird because you replace the type with never in InferArgs:

const okay = ops({
    one: { // okay
        lhs: {
            type: 'string',
            from: (v: string) => String(v)
        },
        rhs: {
            type: 'number',
            from: (v: number) => v.toString()
        },
        compare: (lhs: string, rhs: string) => lhs !== rhs
    }, two: { // error!
        lhs: {
            type: 'string',
            from: (v: string) => String(v)
        },
        rhs: {
            type: 'number',
            from: (v: string) => v.toString()
        },
        compare: (lhs: string, rhs: string) => lhs !== rhs
    },
    three: { // error!
        lhs: {
            type: 'string',
            from: (v: string) => String(v)
        },
        rhs: {
            type: 'number',
            from: (v: number) => v.toString()
        },
        compare: (lhs: number, rhs: string) => lhs !== rhs
    },
    four: { // error
        lhs: {
            type: 'string',
            from: (v: string) => String(v)
        },
        rhs: {
            type: 'number',
            from: (v: number) => v.toString()
        },
        compere: (lhs: string, rhs: string) => lhs !== rhs,
    }
})

I think I'd probably try to make it so that failures are replaced with "the right" type instead of never, which might look like

function ops<T>(ops:
    T & { [K in keyof T]: T[K] extends {
        lhs: { type: infer KL extends keyof ArgTypes, from: (arg: any) => infer RL },
        rhs: { type: infer KR extends keyof ArgTypes, from: (arg: any) => infer RR }
    } ? Args<KL, KR, RL, RR> : Args<keyof ArgTypes, keyof ArgTypes, unknown, unknown> }
): T { return ops };

which results in this, with slightly better error placement:

const okay = ops({
    one: { // okay
        lhs: {
            type: 'string',
            from: (v: string) => String(v)
        },
        rhs: {
            type: 'number',
            from: (v: number) => v.toString()
        },
        compare: (lhs: string, rhs: string) => lhs !== rhs
    }, two: {
        lhs: {
            type: 'string',
            from: (v: string) => String(v)
        },
        rhs: {
            type: 'number',
            from: (v: string) => v.toString() // error, wrong v
        },
        compare: (lhs: string, rhs: string) => lhs !== rhs
    },
    three: {
        lhs: {
            type: 'string',
            from: (v: string) => String(v)
        },
        rhs: {
            type: 'number',
            from: (v: number) => v.toString()
        },
        compare: (lhs: number, rhs: string) => lhs !== rhs // error, wrong lhs
    },
    four: { // error. missing compare
        lhs: {
            type: 'string',
            from: (v: string) => String(v)
        },
        rhs: {
            type: 'number',
            from: (v: number) => v.toString()
        },
        compere: (lhs: string, rhs: string) => lhs !== rhs,
    }
})

And that's about the best I can do, if you need to have a single object literal with all the properties inline, all at once.


Assuming this isn't already-existing code that you're giving types to, though, you don't need to go this route. As you mentioned, you could use a builder pattern or the like to let users build up their object in stages, where each stage requires just a bit of inference to succeed, like using args() multiple times. For example:

class BuildOps<T extends Record<keyof T, Args<any, any, any, any>>> {
    constructor(public ops: T) { }
    add<K extends PropertyKey, KL extends keyof ArgTypes,
        KR extends keyof ArgTypes, RL, RR>(
            key: K,
            args: Args<KL, KR, RL, RR>
        ): BuildOps<T & Record<K, Args<KL, KR, RL, RR>>> {
        return new BuildOps({ ...this.ops, [key]: args } as any);
    }
    build(): { [K in keyof T]: T[K] } {
        return this.ops;
    }
    static emptyBuilder: BuildOps<{}> = new BuildOps({})
    static add = BuildOps.emptyBuilder.add.bind(BuildOps.emptyBuilder);
}

Which could be used like:

const myOps = BuildOps.add("one", {
    lhs: { type: 'string', from: v => String(v) },
    rhs: { type: 'number', from: v => v.toFixed(2) },
    compare: (lhs, rhs) => lhs !== rhs
}).add("two", {
    lhs: { type: 'number', from: v => v > 3 },
    rhs: { type: 'boolean', from: v => v ? 0 : 1 },
    compare(l, r) { return (l ? 0 : 1) === r }
}).build();
/* const myOps: {
    one: Args<"string", "number", string, string>;
    two: Args<"number", "boolean", boolean, 1 | 0>;
} */

That works with the capabilities of the inference algorithm instead of against it.

Playground link to code

  • Related