schl3ck
schl3ck

Reputation: 119

Infer/narrow function argument from sibling property

I want to narrow the type of a function argument based on a sibling property. I know that existential types would help but they are not available so I'm using a helper function. I currently have this thanks to jcalz in the comments

// helper function
function helper<T extends readonly unknown[]>(o: {
  choices: T;
  func: (opts: T[number]) => T[number];
}) {
  return o;
}

helper({
  choices: [
    "one",
    "two",
    "three",
  ], // as const
  func(o) { // should be "one" | "two" | "three", but is string
    return o;
  }
})

I want o in func to be of type "one" | "two" | "three", so my issue is that typescript doesn't infer the choices as a tuple but as string[]. I can't add as const to choices because the function will be called in javascript.

I know that I could split the choices and the function into separate arguments to the helper function, but this is not possible since there will be more properties in the object. The purpose of the properties are to describe func, what it needs as input and what it returns.

The final goal is to have the helper function written in typescript, but used as a type annotation in javascript. Therefore I can't provide any types when calling the function or use as const where it is marked with a comment.

Upvotes: 2

Views: 179

Answers (1)

jcalz
jcalz

Reputation: 328503

You'd like helper() to treat the array literal value ["one", "two", "three"] as having the type ["one", "two", "three"]; that is, as a tuple of string literal types instead of as just string[]. This is easy enough to do from the caller's side of the function; callers can use a const assertion like ["one", "two", "three"] as const. But in cases where the callers don't want to (or cannot) use a const assertion, it would be nice to get the function itself to do it.

I opened microsoft/TypeScript#30680 a while ago asking for some easy way to do this. Maybe it would look like:

// INVALID TYPESCRIPT SYNTAX, DO NOT TRY THIS
//                        vvvvv
function helper<T extends const readonly unknown[]>(o: {
    choices: T;
    func: (opts: T[number]) => T[number];
}) {
    return o;
}

where const could somehow be applied to the generic type parameter. Unfortunately, this is not currently part of the language.

Luckily, there are tricks to get the compiler to behave somewhat similarly.


If you want to give the compiler a hint that it should infer literal types, you can look at microsoft/TypeScript#10676. If a type parameter is constrained to a type that includes string, then the compiler will tend to infer string literal types. The same goes for number. So while an unconstrained T (like T extends unknown) will infer string when it sees "one", a constrained T extends string will infer "one". If you don't really want to constrain the type parameter, then you need to come up with the widest possible constraint that contains string and/or number explicitly. You can't use the unknown type since T extends string | unknown is immediately collapsed to T extends unknown. You can however do something like

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

And then write T extends Narrowable instead of T extends unknown. Or in your case, T extends readonly Narrowable[] instead of T extends readonly unknown[].


If you want the compiler to infer tuples instead of array types, there are other tricks. TypeScript 4.0 introduced variadic tuple types. If U is an arraylike type parameter, then the compiler will tend to see (arr: U) and infer an unordered array type for U. But if you write (arr: [...U]) or `(arr: readonly [...U]), it will tend to infer a tuple type.


Combining these gives:

function helper<T extends readonly Narrowable[]>(o: {
    choices: readonly [...T];
    func: (opts: T[number]) => T[number];
}) {
    return o;
}

And you can verify that it works:

helper({
    choices: [
        "one",
        "two",
        "three",
    ], // as const
    func(o) { // "one" | "two" | "three"
        return o;
    }
})

Looks good.

Playground link to code

Upvotes: 2

Related Questions