Adding to @imagio's answer, you can make a generic type guard (thanks to @wprl for simplification)
function isIn<T>(values: readonly T[], x: any): x is T {
return values.includes(x);
}
And use it with any as const array:
const specialNumbers = [0, 1, 2, 3] as const;
function foo(n: number) {
if (isIn(specialNumbers, n)) {
//TypeScript will say that `s` has type `0 | 1 | 2 | 3` here
}
}
Explanation
Type guards
Functions that return something like x is T are known as type guards. TypeScript docs have a great description of what these are here
Essentially, type guards are functions with return type in form parameterName is Type, where parameterName is name of one of the function parameters. During runtime they actually return a boolean
If the function returns true, TypeScript will assume that the value passed to parameter parameterName has type T. We can use this in true branches of conditional statements. As an example:
function isString(x: unknown): x is string {
return typeof x === 'string'
}
function formatValue(value: number | string): string {
if (isString(value)) {
// This code will be executed only if `isString(value)`
// returned `true`. Since `isString` is a type guard with type
// `x is string`, and we pass `value` to `x`, TypeScript
// will know that `value` has type `string` in this branch
// This is why we can use `.toUpperCase()` here - this
// methods exists on values of type `string`
return value.toUpperCase()
}
// This code is executed only when `isString(value)` returned
// `false`. Here, TypeScript will not that `value` is NOT
// of type `string`. Since `value` parameter has type
// `number | string`, but is surely not `string` in this part of
// code, than it has to have type `number` here
// This is why we can use `.toFixed()` here
// Note that this line would give error, since `.toUpperCase()`
// does not exist on type `number`
//value.toUpperCase()
return value.toFixed(2)
}
Note that for type guard x is T, TypeScript always assumes that if the type guard function returns true, x surely has type T. We
could write isString() incorrectly:
function isString(x: unknown): x is string {
return typeof x === 'number'
}
And TypeScript would not give any error inside of formatValue()
During runtime, we would get an error. If we call formatValue(10), the code would execute like this:
function formatValue(value: number | string): string {
// isString(10) returns `true`, because `typeof 10 === 'number'`
if (isString(value)) {
// We attempt to call (10).toUpperCase(). Since
// Since object Number(10) has no `toUpperCase` property,
// (10).toUpperCase is `undefined`. Since we try to call it
// as a function, we get TypeError from JS, during runtime
return value.toUpperCase()
}
return value.toFixed(2)
}
So it's our responsibility to write type predicates that actually return true only when x has type T
as const and readonly T[]
Imagine that we need to check if value: unknown is one of strings 'a', 'b' or 'c' at runtime, and also let TypeScript infer that value has type 'a' | 'b' | 'c' if that's the case
Runtime check can be done using an array:
const letters = ['a', 'b', 'c']
if (letters.includes(value)) {
// ...
}
But how do we tell TS that value has type 'a' | 'b' | 'c' inside if?
We could use this function:
function isIn<T>(values: T[], x: any): x is T {
return values.includes(x)
}
But the inference doesn't work:
// TS things that T=string here, so `isIn()` returns `x is string`
if (isIn(letters, value)) {
// TS known that `value` has type `string` here, but we
// need a narrower type - 'a' | 'b' | 'c'
}
The root of the problem is that const letters above has type string[]. If we use as const, TS would say that letters has narrower type - ['a', 'b', 'c']`:
// letters is of type readonly ['a', 'b', 'c'] here
const letters = ['a', 'b', 'c'] as const
See What does the "as const" mean in TypeScript and what is its use case? for details
If we try to pass readonly array to isIn(), we get an error:
if (isIn(letters, value)) {
// ^ The type 'readonly ["a", "b", "c"]' is 'readonly'
// and cannot be assigned to the mutable type 'unknown[]'
}
In theory, isIn() could mutate values inside, and TS tries to prevent mutation of values with readonly types. We say that
the parameter of isIn() has readonly type, declaring intent the it does not modify it's parameter:
function isIn<T>(values: readonly T[], x: any): x is T {
return values.includes(x);
}
Finally, type inference works:
// Since `letters` has type `['a', 'b', 'c']` here,
// TS infers `T` in `T[]` to be `'a' | 'b' | 'c'`
if (isIn(letters, value)) {
// So isIn() returns `x is 'a' | 'b' | 'c'`, and
// TS says that `value` has type `'a' | 'b' | 'c'` here
}
type CapitalLetter = typeof CAPITAL_LETTERS[number]... that is, index onnumber, notstring.