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 string
s when using the mutable array.
..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")