Is it possible with typescript to define that keys of state
should be lowercase
+ some string
?
type HasSufix = `${Lowercase<string>}Required` interface SomeShape { [key: HasSufix]: boolean } const state: SomeShape = { usersRequired: false, ordersRequired: false, booksRequired: false, };
Advertisement
Answer
UPDATE FOR TS4.8 AND ABOVE
This now works, as-is!!
As of TypeScript 4.8 there is now support for using the intrinsic string manipulation types with wide types like string
. This was implemented in microsoft/TypeScript#47050 but not mentioned in the TS 4.8 release notes.
So now, Lowercase<string>
only corresponds to lowercase strings (strings which remain unchanged after .toLowerCase()
), whereas in TypeScript 4.7 and below it could match any string
:
type HasSufix = `${Lowercase<string>}Required` interface SomeShape { [key: HasSufix]: boolean } const state: SomeShape = { usersRequired: false, ordersRequired: false, booksRequired: false, oopsieDoodleRequired: false // error! };
ANSWER FOR TS4.7 AND BELOW
There is currently no specific type in TypeScript that corresponds to your desired SomeShape
type. Lowercase<string>
evaluates to just string
; even if this were not true, pattern template literal types like `${string}Required`
cannot currently be used as key types of an object; see microsoft/TypeScript#42192 for more information.
Instead, you could represent SomeShape
as a generic type that acts as a constraint on a candidate type. That is, you make a type like ValidSomeShape<T>
, such that T extends ValidSomeShape<T>
if and only if T
is a valid SomeShape
. It could look like this:
type ValidSomeShape<T extends object> = { [K in keyof T as K extends `${infer P}Required` ? `${Lowercase<P>}Required` : `${Lowercase<Extract<K, string>>}Required`]: boolean } extends infer O ? {[K in keyof O]: O[K]} : never;
The way this works is: the compiler re-maps the keys of T
to those which are valid; if the key K
does not end in "Required"
, then we append it. Otherwise, we turn the part before "Required"
into a lowercase version of itself. And we make sure that the property type is boolean
.
The part at the end with extends infer O ? ...
is a trick from the answer to another question which encourages the compiler to list out the actual properties of ValidSomeShape<T>
in IntelliSense, instead of showing the rather opaque ValidSomeShape<T>
name. You’d rather see {fooRequired: boolean}
in an errormessage instead of ValidSomeShape<{foo: string}>
.
Moving on: to stop people from having to manually specify T
, you can make a generic helper function asSomeShape()
which infers T
from its input:
const asSomeShape = <T extends ValidSomeShape<T>>(obj: T) => obj;
So instead of annotating const state: SomeShape = {...}
, you write const state = asSomeShape({...})
.
Let’s try it out:
const state = asSomeShape({ usersRequired: false, ordersRequired: false, booksRequired: false, }); // okay
This compiles with no error. But watch what happens when you do something incorrect:
const badState1 = asSomeShape({ usersRequired: false, ordersRequired: 123, // error! //~~~~~~~~~~~~~~ <-- // Type 'number' is not assignable to type 'boolean' booksRequired: false, }); // okay const badState2 = asSomeShape({ usersRequired: false, ordersRequired: false, BooksRequired: false, // error! //~~~~~~~~~~~~~~~~~~~~ // Object literal may only specify known properties, but 'BooksRequired' does not exist in type // '{ usersRequired: boolean; ordersRequired: boolean; booksRequired: boolean; }'. // Did you mean to write 'booksRequired'? }); // okay const badState3 = asSomeShape({ users: false, // error! //~~~~~~~~~~~~ // Object literal may only specify known properties, and 'users' does not exist in type // '{ usersRequired: boolean; ordersRequired: boolean; booksRequired: boolean; }' ordersRequired: false, booksRequired: false, }); // okay
You can see that each failure results in a helpful error message. The ordersRequired
property is a number
and not the expected boolean
; the BooksRequired
property should probably be booksRequired
; and the users
property is also wrong (the compiler doesn’t seem to think it’s close enough to usersRequired
to hint that you should write that instead, but it does say that it expects to see usersRequired
in there).
So, this is about as good as it gets, at least as of TypeScript 4.2.
Since a generic constraint is more complicated to use than a specific type, you might want to only use ValidSomeShape<T>
in a function which interacts with objects which have not yet been validated… like the external-facing endpoints of some library. Once you have validated the object, you can widen its type to a less precise but non-generic type like Record<string, boolean>
or something, and pass it around inside your library as that wider type:
export function userFacingLibraryFunction<T extends ValidSomeShape<T>>(someShape: T): void { // now that someShape has been validated, we can pass it to our internal functions: internalLibraryFunction(someShape); } // not exported function internalLibraryFunction(alreadyValidatedSomeShape: Record<string, boolean>): void { Object.keys(alreadyValidatedSomeShape).filter(k => alreadyValidatedSomeShape[k]); }