There is currently no way to represent "a string
containing only digits" as a specific type in TypeScript. Here are some near-ish misses:
`${number}`
Template literal types have some support for "pattern" template literals with "holes" in them, as implemented by microsoft/TypeScript#40598. A type like `${number}`
is interpreted to mean "any string
which could be produced by coercing a number
". Even though it represents the type of a template literal expression, the type it produces is not "literal", but a wide type representing a huge number of possible values without explicitly representing each one. It's like how string
represents the near-infinite number of possible strings without explicitly keeping track of each one.
There are a few oddities with this, such as the fact that such pattern literals cannot currently be used as key types for an object, as reported in microsoft/TypeScript#42192. (you will be able to use these as key types via index signature as of TS4.4, as implemented in microsoft/TypeScript#44512)
But you could get close to your desired type with just `${number}`
:
type StringNumber = `${number}`;
const good: StringNumber[] = [
"0", "10", "25", "8675309"
];
const bad: StringNumber[] = [
"zero", "b4", "23skiddoo" // error!
//~~~~ ~~~~ ~~~~~~~~~~~
//none of these are assignable to `${number}`
];
Of course, actual number
s in TypeScript are not necessarily limited to being composed solely of digits. There are decimal points, exponential markers, sign markers, radix markers, and even hexadecimal digits larger than 9. If you truly want just digits, then `${number}`
is not going to work for you:
const ugly: StringNumber[] = [
"-1.234e+99", // no error, but contains non-digits
"0b101", // ditto
"0xabcdef", // ditto
];
There is no "wide" type that can represent a string consisting only of digits, and while number
is close, it's not
Big Union Of String Literals
This is the same approach that you're currently taking: instead of trying to use a wide type that implicitly represents all digit-only strings, make a big union that explicitly lists out all acceptable string literals. For example:
type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
type MaybeDigit = Digit | '';
type StringNumber = `${Digit}${MaybeDigit}${MaybeDigit}${MaybeDigit}`;
const good: StringNumber[] = [
"0", "10", "25", "8675"
];
const bad: StringNumber[] = [
"zero", "b4", "23skiddoo", "-1.234e+99", "0b101", "0xabcdef" // error!
]
But of course, as you noticed, you are limited to a string of a certain maximum length:
const ugly: StringNumber[] = [
"8675309" // error, but all digits!
]
And this maximum length is small. As mentioned in microsoft/TypeScript#40336, the pull request implementing template literals:
Union types are limited to less than 100,000 constituents, and the following will cause an error:
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type Zip = `${Digit}${Digit}${Digit}${Digit}${Digit}`; // Error
If you really do need long strings, this approach will not work for you.
Generic constraint
Okay, so there's no specific type that works. Let's instead make a generic constraint and represent StringNumber<T extends string>
which takes a string literal type T
and checks it. If it works, then the constraint is met, otherwise it fails:
type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
type StringNumber<T extends string, V = T> = T extends Digit ? V :
T extends `${Digit}${infer R}` ? StringNumber<R, V> : "123456789";
This is a recursive conditional type. StringNumber<"12345">
will evaluate to "12345"
, but StringNumber<"oops">
will evaluate to "123456789"
(some random acceptable string).
And we can use an identity helper function instead of a type annotation. If the function accepts the input it's good, otherwise there will be an error. And since it's an identity function the value is preserved:
const stringNumber = <T extends string>(n: StringNumber<T, T>) => n;
Let's test it out:
stringNumber("0"); // okay
stringNumber("10"); // okay
stringNumber("8675309"); // okay
stringNumber("12345678909876543210"); // okay
stringNumber("zero"); // error!
stringNumber("b4"); // error!
stringNumber("23skiddoo"); // error!
stringNumber("0xabcdef"); // error!
Looks good. The errors are a bit weird, like "zero" is not assignable to "123456789"
, but at least it's an error.
I'm also not sure how long of a number you need to support; there are recursion limits, so if you start putting in truly gargantuan numbers, you'll end up running into them:
stringNumber("123456789098765432101234567890987654321012345678909876543210"); // error!
// Type instantiation is excessively deep and possibly infinite.
There are ways to rewrite recursive types to do a little less recursion, but hopefully you don't need this.
This is still a "near miss" because it forces you to drag around generic type parameters in places you might have hoped to just use a simple type annotation. But if I really needed to accept digit-only-strings like this, I'd recommend this approach.
One possible mitigation would be if you only need this for developer input validation, after which you can just assume that the value has been checked. In that case, you can just enforce the constraint for such validation in developer-facing code, and then widen to string
for use inside your private library implementation, and just assume you've already validated it:
function openLock<T extends string>(combo: StringNumber<T>): boolean {
return openLockInternalImpl(combo);
}
function openLockInternalImpl(combo: string) {
return (combo === "8675309");
}
Playground link to code