Home > Blockchain >  TypeScript type constraint where two strings can be anything as long as they are the same
TypeScript type constraint where two strings can be anything as long as they are the same

Time:10-25

I would like to construct a TypeScript type that ensures that, in an array of objects that each have one key, the keys of those objects are equivalent. The trick is I don't know what that key will be.

For example, In the code below, good1 and good2 would pass because "f1" === "f1" and "f2" === "f2", and bad would fail because "f3" !== "f4".

const good1: MyType = [{ f1: 'Hello' }, { f1: 'World' }];
const good2: MyType = [{ f2: 'Hello' }, { f2: 'World' }];

// @ts-expect-error
const bad: MyType = [{ f3: 'Hello' }, { f4: 'World' }];

The simple way to do it would be something like Record<string, string>[], but that doesn't constrain the keys at all, nor the number of keys. In the code below, I think I want f to be a const of some kind instead of just string:

type MyType = { [f: string]: string }[];

// or if I want a specific number of elements:
type MyTypeTwoElements = [{ [f: string]: string }, { [f: string]: string }];

I don't want to use generics in the variable declaration because I don't know what the strings will be, I just know they'll be equivalent (===). So this won't work:

type MyTypeG<F extends string> = Record<F, string>[];
const withGenericsGood: MyTypeG<"f1"> = [{ f1: 'Hello' }, { f1: 'World' }];
//@ts-expect-error
const withGenericsBad: MyTypeG<"f1"> = [{ f2: 'Hello' }, { f3: 'World' }];

There's probably a more general case where any two strings within a type definition must match each other, not necessarily object keys (and more than two, of course).

TS playground here.

CodePudding user response:

There is no specific type in TypeScript which corresponds to what you hope MyType to be. It would essentially need to be an infinite union of Record<F, string>[] for every string literal type F, like

type MyType = Record<"", string>[] | Record<"a", string>[] | Record<"b", string>[] | ... | 
  Record<"foobar", string>[] | ... | 
  Record<"It was the best of times, it was the blurst of times", string>[] | ...

but TypeScript doesn't support infinite unions. Conceptually an infinite union is equivalent to an existentially quantified generic type, where you say that the generic type parameter exists but you don't know or care about what it is:

type MyType = <exists F extends_oneof string> Record<F, string>[];

but TypeScript supports neither exists to represent existential types (although requested in microsoft/TypeScript#14466 nor extends_oneof to represent that F should range over single literals and not unions of literals (although requested in microsoft/TypeScript#27808 more or less) so you can't really us that approach directly either.


Generally speaking I'd say that the closest you can reasonably get is to use regular generics, as you showed:

type MyTypeG<F extends string> = Record<F, string>[];

and use a generic helper identity function to infer the generic type argument F without having to manually specify it:

const myTypeG = <F extends string>(x: MyTypeG<F>) => x;

As you can test here:

const withGenericsGood = myTypeG([{ f1: 'Hello' }, { f1: 'World' }]); // okay
const withGenericsBad = myTypeG([{ f2: 'Hello' }, { f3: 'World' }]); // error

The withGenericsGood line works because myTypeG() infers "f1" as F and so you get a MyTypeG<"f1"> without having to write it out explicitly. The withGenericsBad line fails because myTypeG() infers F to be the union "f2" | "f3", and neither of the inputs contain both keys. That's not exactly the reason you want this to fail, but it's fairly close. Still, if you really care, you could try to encode all your constraints like this:

type NotAUnion<T> = [T] extends [infer U] ? U extends any ?
    T extends U ? unknown : never : never : never

type StringLiteral<T> = string extends T ? never :
    [T] extends [string & NotAUnion<T>] ? unknown : never;

type MyTypeGInfer<F extends string> = [
    Record<F, string> & StringLiteral<F>,
    ...Record<F & {}, string>[]
];

const myTypeG = <F extends string>(x: MyTypeGInfer<F>): MyTypeG<F> => x;

where we use distributive conditional types to collapse any unions to never in NotAUnion<T>, and other conditional types to ensure that T is a string literal type and not a union of literals or string, and then we use MyTypeGInfer<F> to play inference tricks so that F will be inferred just from the first element of the passed in array and that it will check if F is a single string literal and become never otherwise. Thus you get the following behavior:

myTypeG([{ f1: 'Hello' }, { f1: 'World' }]); // okay
myTypeG([{ f2: 'Hello' }, { f3: 'World' }]); // error, excess property
myTypeG([]); // error, not enough arguments
myTypeG([{ f1: "abc" }]); // okay
myTypeG([{ f1: "abc", f2: "bcd" }, { f1: "cde", f2: "def" }]) // error, not assignable to never
myTypeG([{ ["ab"   "c"]: "abc" }]) // error, not assignable to never

The last one fails because "ab" "c" is known only as a string to the compiler, and thus it can't tell what the key actually is, so it complains.

Personally I think that's probably overkill unless you actually expect people to be doing crazy things with myTypeG().


There are also ways to encode existential types in TypeScript, and but they involve Promise-like inversions of control:

type SomeMyType = <R, >(cb: <F extends string>(x: MyTypeG<F>) => R) => R;
const someMyType = <F extends string>(x: MyTypeG<F>): SomeMyType => cb => cb(x);

const someGood = someMyType([{ f1: 'Hello' }, { f1: 'World' }]); // okay
const someBad = someMyType([{ f2: 'Hello' }, { f3: 'World' }]); // error

The SomeMyType type isn't generic, but it's holding onto a MyTypeG<F> for some F you don't know and it can't tell you. All you can do is give it a generic callback that returns you some type R for any MyTypeG<F> in existence, and then it gives you back an R. The MyTypeG<F> is held in a black box where F is unobservable to you. This completely encodes the existential type, but it's not trivial to use:

function someMyTypeToStringArray(smt: SomeMyType) {
    return smt(x => {
        const k = Object.keys(x[0])[0] as keyof typeof x[number]; 
        // hopefully this is of type F, whatever F is

        return x.map(v => v[k])
    })
}

console.log(someMyTypeToStringArray(someGood).join(" ")); // Hello World

So I wouldn't recommend this unless your need for existentials outweighs the need for ease of use.

Playground link to code

  • Related