1

I have this function where I'm passing a configuration record and the "default" option, which should be one of the keys of the former.

From the usage perspective it should look like this:

useSortBy<Model>({
  sortOptions: {
    nameAsc: {
      fn: (list: Model[]) => list,
      label: "Name A-Z",
    },
    nameDesc: {
      fn: (list: Model[]) => list,
      label: "Name Z-A",
    },
  },
  defaultSortOption: "nameAsc",
});

I can't write a proper definition, so that the defaultSortOption would be inferred from keyof typeof sortOptions and it would expect either 'nameAsc' or 'nameDesc'.

It's fine when I ask for this explicitly:

function useSortBy<T, K extends string>(opts: {
  sortOptions: Record<K, SortOption<T>>;
  defaultSortOption: K;
}) {}

and use as:

useSortBy<Model, "nameAsc" | "nameDesc">({
  // ...
});

But I'd like it to be smart and figure it out itself. Is there a way to do this?

Here are code samples:

  • Please provide a [mre] that clearly demonstrates the issue you are facing. Ideally someone could paste the code into a standalone IDE like [The TypeScript Playground (link here!)](https://tsplay.dev/mpgJzN) and immediately get to work solving the problem without first needing to re-create it. So there should be no pseudocode, typos, unrelated errors, or undeclared types or values. – jcalz Mar 01 '22 at 17:45
  • TS doesn't have partial type argument inference (see [ms/TS#26242](https://github.com/microsoft/TypeScript/issues/26242)) so the workarounds here would either be currying (e.g., `useSortBy()({ /*...*/ }`) or using a dummy parameter (e.g., `useSortBy(null! as Model, { /* ... */ })`). As such this is probably a duplicate of [this](https://stackoverflow.com/q/55754822/2887218) or [this](//stackoverflow.com/q/60377365/2887218) – jcalz Mar 01 '22 at 17:48
  • Hi @jcalz, thank you for your answer. Here's the example of [passing K manually](https://www.typescriptlang.org/play?#code/C4TwDgpgBAsg9gEwgGygXigbwL4G4BQ+okUAynAE7ADyYwAlnAHYA8AKgHzpb5RQBmTAFxQAFMnoBnYCLYBtALoBKdFzYE+yAIYAjFCOkV6TAOYFshfgFcmAYwbMoVyRHJUAQiHYAaKAGkoCAAPYAgmBEkoQ2MTDlE4OkkRTF4oyho6RiYkqAAlCFtKBBY-XzcMh1ZODg0oJH4tK2RgctpKkT9zFUwoC3xnV3TPFngkZF8AciYtAFsIAEFJWwmoAB8oKdmIABEIJYm4lL5JdLasnKO+KGm5xdtk1KuBYTEJaRFRlEUVNC434G8jyu2j0yBEACIAHJbKDzAC0AC1wYCntgUVcbjs9vceE8+IIROIpDJYIgvspVFB-uiniD9FAoTCEXD5sigWjUhy+PVGs1WplmBDMXc2dglAQgA) – Wojciech Grzebieniowski Mar 01 '22 at 22:08
  • ...and with [inferred types](https://www.typescriptlang.org/play?#code/C4TwDgpgBAsg9gEwgGygXigbwL4G4BQ+okUAynAE7ADyYwAlnAHYA8AKgHzpb5RQBmTAFxQAFMnoBnYCLYBtALoBKdFzYE+yAIYAjFCOkV6TAOYFshfgFcmAYwbMoVyRHJUAQiHYAaKAGkoCAAPYAgmBEkoQ2MTDlE4OkkRTF4oyho6RiYkqAAlCFtKBBY-XzcMh1ZODg0oJH4tK2RgctpKkT9zFUwoC3xC7OA0qjasyIwUviYtAFsIAEFJW2TUvkERcSkZWEQURRU0Lglpb1WobT1kEQAiADlZ6HmAWgAta9O+bA+oabmAEQgSxWfDWwjEx228CQyH2qnOW2+ml0+igdweUBeT3m71SFj6zlc6U8okmwwqY2+9UazVamWYN1+EABSxx2CUBCAA) – Wojciech Grzebieniowski Mar 01 '22 at 22:08
  • As you can see here, it infers it as: `Record<"nameDesc", SortOption>;` rather `Record<"nameDesc" | "nameDesc", SortOption>;` I think that proposal you linked could solve my problem in the future, by using something like: `useSortBy` – Wojciech Grzebieniowski Mar 01 '22 at 22:10
  • I'm confused by your example where `Model` is just an empty interface. Weird things happen with empty interfaces so you should [not use them](https://github.com/Microsoft/TypeScript/wiki/FAQ#why-are-all-types-assignable-to-empty-interfaces) for examples. If you add any property to your interface then `(list: Model[]) => list` is not going to be a valid `SortOption`, according to `fn: (list: T[]) => T` in your definition. Should it be `(list: T[]) => T[]` instead? In any case, the example code should be in the question as plain text. – jcalz Mar 02 '22 at 02:00
  • If I'm right about `(list: T[]) => T[]`, then [this](https://tsplay.dev/wjk3vN) is the workaround I usually use (with currying) so you write `useSortBy()(opts)` instead of `useSortBy(opts)`. If you want me to write this up as an answer, let me know. – jcalz Mar 02 '22 at 02:26
  • You are right, it was supposed to be `(list: T[]) => T[]`. Thank you very much for your solution there. – Wojciech Grzebieniowski Mar 02 '22 at 09:12
  • I'll write up an answer when I get a chance, but could you [edit] the question with a plaintext [mre] so that there's enough info in there to answer it? – jcalz Mar 02 '22 at 15:53

2 Answers2

1

The big problem you're running into here is that TypeScript does not currently support partial type parameter inference as requested in microsoft/TypeScript#26242. With a generic function like useSortBy<T, K>({/*...*/}) you can either manually specify both T and K type parameters, or the compiler can try to infer both type T and K type parameters. There's no direct way to define it so that T must be manually specified but that K should be inferred.

The workaround I tend to use in these cases is currying; make useSortBy a function generic in only T, and have it return another function generic in only K. This changes the call from the desired useSortBy<T>({/*...*/}) to the slightly weird useSortBy<T>()({/*...*/}), but it works.


The second problem you're running into is that the compiler is using the defaultSortOption parameter to infer K instead of the sortOptions parameter. So you only get "nameDesc" instead of "nameAsc" | "nameDesc". What you want to tell the compiler is that the K in the type of defaultSortOption should be a non-inferential use of the type parameter; it should infer K from sortOptions alone, and then just check defaultSortOption with it. Such non-inferential type parameter usage is requested in microsoft/TypeScript#14829, but there is no direct support for this. Again, though, there are workarounds.

One workaround you can use is to introduce a second type parameter (let's call it L) which is constrained to the first one. You can use this new type parameter instead of K anywhere you'd like to be non-inferential. Then the compiler will infer K only from the places you want (because those are the only places mentioning K at all), and whatever it infers for L will be checked against K.


Let's use both of these workarounds in your useSortBy example. For clarity I'm introducing the following types

interface Model { name: string, age: number }

type SortOption<T> = {
  fn: (list: T[]) => T[];
  label: string;
}

Now here is the curried and extra-type-parametered useSortBy():

const useSortBy = <T,>() => <K extends string, L extends K>(opts: {
  sortOptions: Record<K, SortOption<T>>;
  defaultSortOption: L;
}) => { }

Let's use it:

useSortBy<Model>()({
  sortOptions: {
    nameAsc: {
      fn: (list) => list.slice().sort((a, b) => a.name.localeCompare(b.name)),
      label: "Name A-Z",
    },
    nameDesc: {
      fn: (list) => list.slice().sort((a, b) => b.name.localeCompare(a.name)),
      label: "Name Z-A",
    }
  },
  defaultSortOption: "nameDesc",
});

That works fine and note that the list callback parameters are contextually types to be Model[] without needing explicit type annotations. And if you put a value for defaultSortOption that does not exist as a key of the sortOptions property, you see the desired error in the desired place:

useSortBy<Model>()({
  sortOptions: {
    nameAsc: {
      fn: (list) => list.slice().sort((a, b) => a.name.localeCompare(b.name)),
      label: "Name A-Z",
    },
    nameDesc: {
      fn: (list) => list.slice().sort((a, b) => b.name.localeCompare(a.name)),
      label: "Name Z-A",
    }
  },
  defaultSortOption: "oopsieDoodle", // error!
  //~~~~~~~~~~~~~~~ <-- Type '"oopsieDoodle"' is 
  // not assignable to type '"nameAsc" | "nameDesc"'
});

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
0

You can take advantage of both an indexed access type on the type itself and keyof to return the keys of a given object key.

type SortBy<Model> = {
  sortOptions: {
    nameAsc: {
      fn: (list: Model[]) => void,
      label: "Name A-Z",
    },
    nameDesc: {
      fn: (list: Model[]) => void,
      label: "Name Z-A",
    },
  },
  defaultSortOption: keyof SortBy<Model>['sortOptions'],
}
  • It looks like OP wants to support `useSortBy()` with different `K`s and not just one hardcoded into `SortBy`, so I don't see how this answer addresses the question as asked. – jcalz Mar 01 '22 at 21:51