Skip to content
Advertisement

Type for key with suffix

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,
};

Answer

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]);
}

Playground link to code

Advertisement