Home > Net >  Enforce readonly array parameter for using the values of one key as string union
Enforce readonly array parameter for using the values of one key as string union

Time:09-13

I was wondering whether it is possible to enforce a caller to provide a readonly array as function parameter. Specifically, I want to pass an array of objects to a function and its second parameter is the value of one of the keys of the objects (as a string union). For that to work with TypeScript, it needs to be readonly, because TypeScript "loses" the string literals by widening them to strings when using the mutable array.

Here is a playground

..and here is an example:

We have a Choice and a function to select one from an array:

type Choice = {
    choiceId: string,
    label: string
}

const choices: Choice[] = [{ choiceId: "choiceId1", label: "label 1" }]

function selectChoice<TChoices extends ReadonlyArray<Choice>>(
    choices: TChoices,
    initialChoiceId: TChoices[number]["choiceId"]
): Choice | null {
    return choices.find(choice => choice.choiceId === initialChoiceId) ?? null
}

// call the function and get auto-complete/type-safe usage for the ID
selectChoice(choices, "choiceId1")

For this to work, the choices input must be a readonly array (TChoices extends ReadonlyArray<Choice>). A normal array is not enough.

Here are different ways to call the function:

// Example 1: This should fail (wrong ID), but doesn't, because the choices are not readonly.
selectChoice(
    [{ choiceId: "id1", label: "label 1" }, { choiceId: "id2", label: "label 2" }],
    "wrong-id-on-purpose"
)

// If we instead define the array `as const`, it works.
const choicesAsConst =
    [{ choiceId: "id1", label: "label 1" }, { choiceId: "id2", label: "label 2" }] as const

// Example 2: works
selectChoice(choicesAsConst, "id1")
// Example 3: works, fails on purpose
selectChoice(choicesAsConst, "wrong-id-on-purpose")

Can you somehow force the caller to provide a readonly array? Otherwise it would be easy to not recognize that you're not using the function in a type-safe manner (see "Example 1" above). Does one maybe need to have a custom type guard to check this?

Or maybe there is a better way alltogether? e.g. I know this works when providing an object instead of an array of objects, but in the described use case, an array makes more sense for me.

CodePudding user response:

You can detect the passed array to be readonly by checking if it has a non-readonly property like push.

function selectChoice<TChoices extends readonly Choice[]>(
    choices: TChoices,
    initialChoiceId: TChoices extends { push: any } 
      ? never 
      : TChoices[number]["choiceId"]
): Choice | null {
    return choices.find(choice => choice.choiceId === initialChoiceId) ?? null
}

This following example still fails even though TypeScript could infer that this is correct:

selectChoice([{ choiceId: "id1", label: "label 1" }], "id1") 
// Error: this array isn't readonly

Also note that checking if the array is readonly is not enough since normal arrays can also be readonly.

selectChoice(choicesAsConst as readonly Choice[], "bla") 
// this should not work

If you also want the example from above to succeed, you will have to tell TypeScript that the choidId field needs to be narrowed to a string literal type.

type Choice<C> = {
    choiceId: C,
    label: string
}

function selectChoice<TChoices extends readonly Choice<C>[], C extends string>(
    choices: TChoices,
    initialChoiceId: TChoices[number]["choiceId"] extends infer U 
      ? string extends U 
        ? never
        : U
      : never
): Choice<string> | null {
    return choices.find(choice => choice.choiceId === initialChoiceId) ?? null
}

And if TChoices[number]["choiceId"] turns out to be just a string and not a string literal type, we can evaluate initialChoiceId to never.

// works
selectChoice(choicesAsConst, "id1")
// error
selectChoice(choicesAsConst, "wrong-id-on-purpose")

// works
selectChoice([{ choiceId: "id1", label: "label 1" }], "id1")
// error
selectChoice([{ choiceId: "id1", label: "label 1" }], "wrong-id-on-purpose")

// error
selectChoice(choicesNotConst, "id1")
// error
selectChoice(choicesNotConst, "wrong-id-on-purpose")

Playground

  • Related