11

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.

5
  • 1
    Given that the types {a: string; b: number} and {b: number; a: string} are the same, I'm not sure what you expect to see here. Commented Apr 4, 2019 at 18:17
  • Somewhere in the code you're using it must assume that the keys come in a particular order; do you have access to that code? That is, you are essentially dependent both on Book as well as another type like type PropertyOrder = ["title", "authors", "date", "pages"]. If you can get a hold of that type, you can make a TupleFromInterface<Book, PropertyOrder> that works. Commented Apr 4, 2019 at 19:05
  • You're right, there is an array that establishes the canonical order of the properties. I'll edit the question. Commented Apr 4, 2019 at 20:20
  • 1
    @jcalz AFAIK, TS doesn't have a way to iterate through tuple/array type elements. So to impl this, it'll involve some crazily long and cumbersome overloads in order to make it generic. But yeah it is possible. Commented Apr 4, 2019 at 20:34
  • @jcalz sorry I misread your comment - the class contains that array at runtime, but the type definitions don't know how the class will be implemented. Commented Apr 4, 2019 at 20:38

2 Answers 2

8

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!

Sign up to request clarification or add additional context in comments.

6 Comments

Wait what? I actually got the same result, a struct like { 0: SomeType ...}, but does this qualify as a tuple? I didn't do the finally check, I thought it was some number-keyed object-ish thing. 😂
If you're using TS3.1 or higher, you should be able to map tuples into other tuples.
I used the TS playground. My approach is basically identical to yours, it's just the way TS displayed the result lead me to believe I got wrong answer. Me first time seen a tuple displayed this way. Lesson learned. ^ Good source btw, thanks.
I can't reproduce that type of display on either the Playground or its friends. Looks like a regular tuple to me. 🤷
@jcalz - this is an amazing answer, thanks so much.
|
4

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.

2 Comments

I have a definition of TupleFromInterface in my answer, in case you're interested.
@jcalz I updated my answer to show my original approach.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.