Issue
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.
Solution
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")
Answered By - Tobias S.
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.