I'd like to type an array so that the only allowed values in the array are the values of a certain key in a list of objects.
In prinicple, I know this can be done like this:
const page1 = {
videoTitles: ['someTitle', 'otherTitle'],
} as const
const page2 = {
videoTitles: ['title', 'goodTitle'],
} as const
const allVideos = [...page1.videoTitles, ...page2.videoTitles]
// As desired, the if statement raises an error because 'badTitle' isn't in either of the lists
if (allVideos.includes('badTitle')) {
console.log('Found!')
}
However, what I'd like to do is give a type to the objects and get the same behavior, but I just can't figure out how to do it. Here's roughly what I'd like:
type PageWithVideos = { readonly videoTitles: readonly string[] }
const page1: PageWithVideos = {
videoTitles: ['someTitle', 'otherTitle'],
} as const
const page2: PageWithVideos = {
videoTitles: ['title', 'goodTitle'],
} as const
const allTitles = [...page1.videoTitles, ...page2.videoTitles]
// Now, however, I don't get the same error. allTitles is just a string[]
if (allTitles .includes('badTitle')) {
console.log('Found!')
}
I've found that I can really force it with something like this:
type PageWithVideos<T extends string> = { readonly videoTitles: readonly T[] }
const page1: PageWithVideos<'someTitle' | 'otherTitle'> = {
videoTitles: ['someTitle', 'otherTitle'],
} as const
const page2: PageWithVideos<'title' | 'goodTitle'> = {
videoTitles: ['title', 'goodTitle'],
} as const
const allVideos = [...page1.videoTitles, ...page2.videoTitles] as const
// I'm getting the error again (which is good), but this is a little silly
if (allVideos.includes('badTitle')) {
console.log('Found!')
}
But I would prefer not to have to duplicate each title in the list and the generic. Any thoughts would be appreciated!
Edit - Use case:
Building a FE only portfolio site for a documentarian. The videos are hosted on external video platforms (YouTube, Vimeo) and are being embedded into the site.
The customer has, say, 100 videos. There are ~7 different views for the videos. The customer would like to be able to, on each screen, select which videos are displayed and in which order. Many videos would appear on multiple screens.
I'd like to deliver a solution in which the customer can add Video
objects to some videos.ts
file. When adding the videos, the values are type-checked. Then I want the customer to go to another file (recetWork.ts
) and add the titles of the videos he'd like shown on that page. But what I really want is for the list of titles on recentWork.ts
to be type-checked against the list of all video names in videos.ts
.
// videos.ts
export const videos: Video[] = [{
title: 'someTitle',
...
}, {
title: 'title',
...
}]
// recentWorks.ts
export const recentWorkPageInfo = {
videoTitles: ['title', 'someTitle'], // type-checked against `title` key from videos
...
}
Solution: @ghybs got it!
// videos.ts
export const videos = [{
title: 'someTitle',
...
}, {
title: 'title',
...
}] as const satisfies Video[]
// recentWorks.ts
import { videos } from '../videos'
type PageInfo = {
videoTitles: (typeof videos)[number]['title'][]
}
export const recentWorkPageInfo = {
videoTitles: ['title', 'someTitle'], // type-checked!!
...
}
This sounds like the use case for the satisfies
operator (introduced in TypeScript 4.9):
The new
satisfies
operator lets us validate that the type of an expression matches some type, without changing the resulting type of that expression.
So you can catch objects which do not comply with the shape of PageWithVideos
:
const page3 = {
videoTitles: [0], // Error: Type 'number' is not assignable to type 'string'.
// ~
} as const satisfies PageWithVideos
const page4 = {
titles: ["badKey"], // Error: Object literal may only specify known properties, and 'titles' does not exist in type 'PageWithVideos'.
// ~~~~~~
} as const satisfies PageWithVideos
...but their type is still correctly inferred (in particular their literal type from their content, thanks to the as const
assertion), and you keep your desired behaviour:
const page1 = {
videoTitles: ['someTitle', 'otherTitle'],
} as const satisfies PageWithVideos
const page2 = {
videoTitles: ['title', 'goodTitle'],
} as const satisfies PageWithVideos
const allTitles = [...page1.videoTitles, ...page2.videoTitles]
if (allTitles.includes('badTitle')) { // Error: Argument of type '"badTitle"' is not assignable to parameter of type '"someTitle" | "otherTitle" | "title" | "goodTitle"'.
// ~~~~~~~~~~
console.log('Found!')
}