Tobias Wicker
Tobias Wicker

Reputation: 713

Type definition for dynamic, infinite, numbered variable names in TypeScript

I'm writing a type definition file (index.d.ts) for a jQuery library that doesn't come with a type definition.

Some of the methods require an object as argument that has a subset of all available attributes set. Besides the explicitly defined variables, the library offers to use an infinite amount of numbered variables (essentially an implementation of an array without the benefits of an actual array).

export interface SomeLibOption {
    a?: number,
    b?: string,
    c?: boolean,

    // so far so good, now the problematic part:
    x?: number,
    x2?: number,
    x3?: number,
    x4?: number,
    x5?: number,
    // ...
    x1000?: number,
    // ...
    x500000?: number,
    // ...
}

declare global {
    interface JQuery<TElement = HTMLElement> {
        setFoo(foo: SomeLibOption): this;
        setBar(bar: SomeLibOption): this;
    }
}

Usage looks like this:

$('someSelector').setBar({
    a: 2,
    x: 3,
    x1: 9,
    x2: 5,
    x3: 4,
    // ...
    x1000: 42,
    // ...
});

Hardcoding all possible variable names seems inviable. They are infinite after all.

How do you define those in a type definition file? Is this even possible?

Clarification

I am looking for a solution, that defines 'x' + number as a valid attribute, but not 'y' + number while keeping existing definitions such as a?: number, b?: string, c?: boolean.

Basically the attributes should match the regular expression /^x[0-9]+$/.

Upvotes: 1

Views: 1492

Answers (2)

jcalz
jcalz

Reputation: 330086

TypeScript 4.1 will support template literal types like `${A}${B}` to represent string concatenation. It will also support recursive conditional types.

UPDATE FOR TS4.4+

TypeScript 4.4 will support using pattern template literals (as implemented in microsoft/TypeScript#40598) in index signatures; see microsoft/TypeScript#44512, at which point the following code will simply work:

interface SomeLibOption {
  a?: number;
  b?: string;
  c?: boolean;
  x?: number;
  [k: `x${number}`]: number;
}

const foo: SomeLibOption = {};
foo.x = 1;
foo.x12345 = 2; // okay
foo.xNope = 3; // error!

Playground link to code


ORIGINAL ANSWER FOR TS4.1-4.3

You won't be able to represent the infinite set of properties of the form "x"+number as a concrete type in TypeScript, at least not as far as I can see. There is some support for a template literal type like `x${number}`, according to microsoft/TypeScript#40598 as in:

type XNumber = `x${"" | number}`;
let xNumber: XNumber;
xNumber = "x12345"; // okay
xNumber = "xNope"; // error!
xNumber = "y"; // error!
xNumber = "x-3.14159"; // okay I guess

but it does not extend to key types the way you'd need it here (see microsoft/TypeScript#42192):

const foo: Partial<Record<XNumber, number>> = {};
foo.x = 1;
foo.x12345 = 2; // error! 😢

Without that, the best you'll be able to do is represent SomeLibOption as a generic constraint instead of as a specific type.

Template literal manipulation is currently tricky, as there are two main pitfalls: explosion of union types, and recursion limits. Conceptually, you can come up with a Digit type like 0|1|2|3|4|5|6|7|8|9 and then say you'll accept a key of type "x" followed by, say, `${Digit}${Digit}${Digit}${Digit}${Digit}${Digit}`. But that is an enormous union and it breaks the compiler. So instead you can represent it as a recursive type check like type IsDigits<T extends string> = T extends "" ? "yes" : `${Digit}${infer U}` ? IsDigits<U> : "no" but that type ends up bombing out on long string like IsDigits<"13263548274827"> due to recursion limits.

The following tries to walk the line between them by using ${Digit}${Digit} for a modest union of 100 elements and doing recursion for groups of two digits or one digit:

interface BaseLibOption {
    a?: number,
    b?: string,
    c?: boolean,
}

type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type IsAllDigits<T extends string, Y = T, N = never> = string extends T ? N :
    T extends "" ? Y : T extends `${Digit}${Digit}${infer U}` ? IsAllDigits<U, Y, N> :
    T extends `${Digit}${infer U}` ? IsAllDigits<U, Y, N> : N
type SomeLibOption<T> = { [K in keyof T]?: K extends keyof BaseLibOption ? BaseLibOption[K] :
    K extends `x${infer N}` ? IsAllDigits<N, number> : never }

It's a mess, but here's your JQuery methods:

interface JQuery<TElement = HTMLElement> {
    setFoo<T extends SomeLibOption<T>>(foo: T): this;
    setBar<T extends SomeLibOption<T>>(bar: T): this;
}

declare const $: JQuery;

Let's see if it works:

$.setFoo({
    a: 1,
    b: "two",
    c: true,
    x: 234,
    x999: 2,
    x8675309: 1,
})

$.setBar({
    a: "oops", // error!
    b: 2, // error!
    c: true,
    x: 123,
    y: 345, // error!
    x23Skidoo: 678, // error!
})

Looks right, I think.

Playground link to code

Upvotes: 2

Hoang
Hoang

Reputation: 411

You can define an interface that will have an arbitrary number of properties whose key is always a string and value is always a number using this notation

interface MyInterface {
  [key: string]: number
}

Below is an example that uses the same interface name as the one in your question and a simple demonstration of how to use it in function definition and invocation.

// Define an interface with an arbitrary of key value pairs in which
// keys are always strings and values are always numbers
interface SomeLibOption {
  [key: string]: number
}

// Example function with a parameter of type
// SomeLibOption defined above
function setBar(op: SomeLibOption) {
  // Do something...
  // For example, the following line prints out sum of op.a and op.b
  console.log(`a + b = ${op.a + op.b}`)
}

// Example function call passing in a SomeLibOption
// object that has arbitrary key value pairs
setBar({
  a: 1,
  b: 2,
  c: 3,
  a2: 400,
  b2: 500
})

Note that the semicolons ; after each statement are omitted as we assume that we are using the version of TypeScript that allows that.

Upvotes: 0

Related Questions