Seph Reed
Seph Reed

Reputation: 10918

Typescript function which returns one item from an array with proper type

I have a function that returns one item from an array:

function oneItem(arr: any[]) {
  return arr[~~(Math.random() * arr.length)];
}

I'd like to have approriate types for this.


What I've Tried:

type ValuesOf<T extends any[]>= T[number];
function oneItem<ARRAY extends any[]>(arr: ARRAY): ValuesOf<ARRAY> { ... }

const a = oneItem(["foo", "bar"]);
// typeof a is (string) instead of ("foo" | "bar")

Found here (https://github.com/Microsoft/TypeScript/issues/20965)


How do I return a single item from an array with proper type?


EDIT: I figured it out for readonly arrays:

type ValuesOf<T extends readonly any[]>= T[number];
function oneItem<ARRAY extends readonly any[]>(arr: ARRAY): ValuesOf<ARRAY> { ... }

const a = oneItem(["foo", "bar"] as const);

Upvotes: 6

Views: 4700

Answers (1)

jcalz
jcalz

Reputation: 327799

When you say "proper" type, I guess you mean you want the narrowest possible type the compiler can infer.

Whether or not this is "proper" is in the eye of the beholder; the runtime code let x = 0 could have many possible typings in TypeScript, and the compiler has to pick just one of them unless you annotate the type yourself.

For example, I might want it to be interpreted as let x: number = 0 which is the default compiler behavior and which makes sense if I plan to assign different numbers to x later. Or I might want let x: 0 | 1 = 0 because I'm only going to write code like x = y & 1 and I know that only those values will come out. Or I might want let x: 0 = 0 because x will always be 0 (although presumably I would use const in that case). Or maybe I want let x: number | string = 0 because sometimes I will assign string values to it. Or maybe I want any other crazy type that includes 0 as a value.

If you don't annotate it yourself, then the compiler has to infer the type, so it makes an assumption: non-readonly and non-const values will tend to be inferred as a "widened" type like string, number, or boolean, while readonly and const values will tend to be inferred as a "narrow" literal type like "foo", 0, or true.

Sometimes the compiler's default assumption won't match the intent of the developer. That doesn't mean the compiler did anything wrong; it's not as if it takes let x = 0 and infers string for x. It just means the developer might be required to put in some more effort to communicate his or her intent.


So, when I write ["foo", "bar"], the compiler tends to infer that as string[]. If I want something else, I can annotate it myself. Let's start with this version of your function:

function oneItem<T>(arr: readonly T[]): T {
  return arr[~~(Math.random() * arr.length)];
}

Here are some outputs:

let arr1 = ["foo", "bar"]; // inferred as string[]
const res1 = oneItem(arr1); // string

let arr2: Array<"foo" | "bar"> = ["foo", "bar"];
const res2 = oneItem(arr2); // "foo" | "bar"

let arr3: ["foo", "bar"] = ["foo", "bar"];
const res3 = oneItem(arr3); // "foo" | "bar"

let arr4: readonly ["foo", "bar"] = ["foo", "bar"];
const res4 = oneItem(arr4); // "foo" | "bar"

let arr5 = ["foo", "bar"] as const; // inferred as readonly ["foo", "bar"];
const res5 = oneItem(arr5);

You can see that arr1 uses the default string[] inferred type, and thus string comes out of the function. For arr2, arr3, and arr4, I've annotated the arrays as progressively narrower types, and all of them allow "foo" | "bar" to come out of the function. The last one, arr5, uses a const assertion to ask the compiler to infer the type as the narrowest value it can, corresponding to that of arr4.

So one way for you to proceed is just to specify the value's type more narrowly.


Of course, in your use case, you have something like

const resArrayLiteral = oneItem(["foo", "bar"]); // string

And you would like "foo" | "bar" to come out instead of string. Why should the caller of oneItem() have to ask the compiler to narrow the type of the array literal ["foo", "bar"]? Can't the implementer of oneItem() use some sort of contextual typing hint to tell the compiler to interpret things more narrowly?

Well, kind of, yes. It's just not pretty. Here's one way to do it:

type Narrowable = string | number | boolean | symbol | object | undefined | void | null | {};

function oneItem<T extends Narrowable>(arr: readonly T[]): T {
  return arr[~~(Math.random() * arr.length)];
}

Here we are giving T a constraint to Narrowable, a union type of a bunch of things including string, number, and boolean. In fact, the Narrowable type is a kind of tedious version of unknown, since most anything should be assignable to it. But Narrowable gives the compiler a hint (see microsoft/TypeScript#10676) that you'd like to see T inferred as a literal type, if possible.

Let's see if it works:

const resArrayLiteral = oneItem(["foo", "bar"]); // "foo" | "bar"

Looks good now! T is inferred as "foo" | "bar" and not string.


As I said, though: it's not pretty. There's an open issue, (microsoft/TypeScript#30680), suggesting that we be able to write the oneItem() signature as something like

function oneI<const T>(arr: readonly T[]): T; // currently invalid syntax

where the const is in the signature, and it behaves similarly to having the caller use as const. Not sure if that issue will ever go anywhere, though. For now, we have to jump through hoops like Narrowable.


Also, please note that the above T extends Narrowable does nothing to change the behavior of arr1. If you write let arr1 = ["foo", "bar"], then arr will be inferred as string[]. Once it's a string[], the compiler has forgotten all about "foo" and "bar" as literal types. And thus oneItem(arr1) will return string; T will be inferred as string. So at some point you might find yourself needing to write narrow types yourself, if you want them.


Okay, hope that helps; good luck!

Playground Link to code

Upvotes: 4

Related Questions