Herb Caudill
Herb Caudill

Reputation: 49952

Typescript: Create tuple from interface

Is it possible to generate a tuple type like [string, date, number] from an interface like {a: string, b: date, c: number}?

Scenario

I'm trying to add typings to a function where you can either pass an object, or the values of the object's properties, in order. (Don't @ me, I didn't write the code.)

// This is valid
bookRepo.add({
  title: 'WTF',
  authors: ['Herb Caudill', 'Ryan Cavanaugh'],
  date: new Date('2019-04-04'),
  pages: 123,
})

// This is also valid
bookRepo.add([
  'WTF', // title
  ['Herb Caudill', 'Ryan Cavanaugh'], // authors
  new Date('2019-04-04'), // date
  123, // pages
])

So what I'm imagining is a way to generate a tuple that contains an interface's properties' types:

interface Book {
  title: string
  authors: string | string[]
  date: Date
  pages: number
}

type BookTypesTuple = TupleFromInterface<T>
// BookTypesTuple =  [
//   string,
//   string | string[],
//   Date,
//   number
// ]

so I could do something like this:

class Repo<T> {
  // ...
  add(item: T): UUID
  add(TupleFromInterface<T>): UUID
}

Edit The class does have an array property that defines the canonical order of fields. Something like this:

const bookRepo = new Repo<Book>(['title', 'authors', 'date', 'pages'])

I'm authoring type definitions for the generic Repo, though, not for a specific implementation. So the type definitions don't know in advance what that list will contain.

Upvotes: 11

Views: 6101

Answers (2)

hackape
hackape

Reputation: 19957

It is possible to write util-like types in TS. However, for your use case it's impossible.

Key order doesn't matter in object-like interface, while it does in array-like interface. The information of order doesn't exist in the input, thus there's no way to derive such output out of nowhere.


Edit 1: in response to OP's edit: It seems there exists a solution at first sight, since all necessary information is given. However, due to limitation in TypeScript's type definition language, I cannot find a way to implement such util type TupleFromInterface that meets your need. So far the best result I can get is:

type result = TupleFromInterface<Book, ['title', 'authors', 'date', 'pages']>
// yields:
type result = {
  0: string;
  1: string | string[];
  2: Date;
  3: number;
}

I cannot find a generic way to convert this result to the tuple we want. So close to success 😫! If anyone has any idea how to solve this puzzle, let me know!


Edit 2: in response to @jcalz answer:

This is my approach that produces the funny-looking misleading tuple display.

type ArrayKeys = keyof any[]
type Indices<T> = Exclude<keyof T, ArrayKeys>
type Lookup<T, K> = K extends keyof T ? T[K] : never;
type TupleFromInterface<T, K extends Array<keyof T>> = {
  [I in Indices<K>]: Lookup<T, K[I]>
}

Difference is I use [I in Indices<K>] instead of [I in keyof K]. Prior to the change introduced in TS v3.1, keyof Array<any> also includes things like "length" | "indexOf", that's why I use Indices to filter them out.

It turns out this approach is not only unnecessary in v3.1+, but also imperfect.

type TupleLike = { 0: number };
let foo: TupleLike;
foo = [1] // good
foo = [1, 'string'] // <- also accepted, not ideal
foo = ['string'] // bad

Conclusion, my approach is a legacy workaround, when using TS v3.1+, refer to @jcalz's answer.

Upvotes: 4

jcalz
jcalz

Reputation: 327994

If the Repo constructor takes a tuple of property names, then that tuple type needs to be encoded in the type of Repo for the typing to work. Something like this:

declare class Repo<T, K extends Array<keyof T>> { }

In this case, K is an array of keys of T, and the signature for add() can be built out of T and K, like this:

type Lookup<T, K> = K extends keyof T ? T[K] : never;
type TupleFromInterface<T, K extends Array<keyof T>> = { [I in keyof K]: Lookup<T, K[I]> }

declare class Repo<T, K extends Array<keyof T>> {
  add(item: T | TupleFromInterface<T, K>): UUID;
}

And you can verify that TupleFromInterface behaves as you want:

declare const bookRepo: Repo<Book, ["title", "authors", "date", "pages"]>;
bookRepo.add({ pages: 1, authors: "nobody", date: new Date(), title: "Pamphlet" }); // okay
bookRepo.add(["Pamplet", "nobody", new Date(), 1]); // okay

To be complete (and show some hairy issues), we should show how the constructor would be typed:

declare class Repo<T extends Record<K[number], any>, K extends Array<keyof T> | []> {
  constructor(keyOrder: K & (keyof T extends K[number] ? K : Exclude<keyof T, K[number]>[]));
  add(item: T | TupleFromInterface<T, K>): UUID;
}

There's a lot going on there. First, T is constrained to Record<K[number], any> so that a rough value of T can be inferred from just K. Then, the constraint for K is widened via a union with the empty tuple [], which serves as a hint for the compiler to prefer tuple types for K instead of just array types. Then, the constructor parameter is typed as an intersection of K with a conditional type which makes sure that K uses all of the keys of T and not just some of them. Not all of that is necessary, but it helps catch some errors.

The big remaining issue is that Repo<T, K> needs two type parameters, and you'd like to manually specify T while leaving K to be inferred from the value passed to the constructor. Unfortunately, TypeScript still lacks partial type parameter inference, so it will either try to infer both T and K, or require you to manually specify both T and K, or we have to be clever.

If you let the compiler infer both T and K, it infers something wider than Book:

// whoops, T is inferred is {title: any, date: any, pages: any, authors: any}
const bookRepoOops = new Repo(["title", "authors", "date", "pages"]);

As I said, you can't specify just one parameter:

// error, need 2 type arguments
const bookRepoError = new Repo<Book>(["title", "authors", "date", "pages"]);

You can specify both, but that is redundant because you still have to specify the parameter value:

// okay, but tuple type has to be spelled out
const bookRepoManual = new Repo<Book, ["title", "authors", "date", "pages"]>(
  ["title", "authors", "date", "pages"]
);

One way to circumvent this is to use currying to split the constructor into two functions; one call for T, and the other for K:

// make a curried helper function to manually specify T and then infer K 
const RepoMakerCurried = <T>() =>
  <K extends Array<keyof T> | []>(
    k: K & (keyof T extends K[number] ? K : Exclude<keyof T, K[number]>[])
  ) => new Repo<T, K>(k);

const bookRepoCurried = RepoMakerCurried<Book>()(["title", "authors", "date", "pages"]);

Equivalently, you could make a helper function which accepts a dummy parameter of type T that is completely ignored but is used to infer both T and K:

// make a helper function with a dummy parameter of type T so both T and K are inferred
const RepoMakerDummy =
  <T, K extends Array<keyof T> | []>(
    t: T, k: K & (keyof T extends K[number] ? K : Exclude<keyof T, K[number]>[])
  ) => new Repo<T, K>(k);

// null! as Book is null at runtime but Book at compile time
const bookRepoDummy = RepoMakerDummy(null! as Book, ["title", "authors", "date", "pages"]);

You can use whichever of those last three solutions bookRepoManual, bookRepoCurried, bookRepoDummy bothers you the least. Or you can give up on having Repo track the tuple-accepting variant of add().

Anyway, hope that helps; good luck!

Upvotes: 8

Related Questions