简体   繁体   中英

Infer string literal type from string manipulation

I've got a function that takes a string and creates an object mapping the four CRUD actions to "action" strings containing the argument:

function createCrudActions(name: string) {
  const record = name.toUpperCase();

  return {
    create: `CREATE_${record}`,
    read: `READ_${record}`,
    update: `UPDATE_${record}`,
    delete: `DELETE_${record}`,
  };
}

Rather than have the type of each property in the returned object be string , I wanted to see if I could make them string literal types. I tried using template literal types to achieve this:

type CrudActions = "create" | "read" | "update" | "delete";

type Actions<T extends string> = {
  [K in CrudActions]: `${Uppercase<K>}_${Uppercase<T>}`;
}

function createCrudActions<T extends string>(name: T) {
  const record = name.toUpperCase();

  return {
    create: `CREATE_${record}`,
    read: `READ_${record}`,
    update: `UPDATE_${record}`,
    delete: `DELETE_${record}`,
  };
}

const postActions: Actions<"post"> = createCrudActions("post");

But with this code, the TypeScript compiler doesn't see the function's return value as assignable to Actions<T> —the object's properties are still string . The error is:

Type '{ create: string; read: string; update: string; delete: string; }' is not assignable to type 'Actions<"post">'. Types of property 'create' are incompatible. Type 'string' is not assignable to type '"CREATE_POST"'.

I tried using const assertions ( as const ) on each property value, as well as on the returned object itself, but the property types remain strings. Is there any way to do this without just casting the returned object ( as Actions<T> )? If so, that would kind of defeat the purpose, so I'm hoping there's some way to make the compiler understand. But I think it might not be able to determine that the runtime toUpperCase call corresponds to the Uppercase transformation in the definition of Actions<T> .

TS Playground

Edit: Another approach that is close but not quite what I want:

type CrudActions = "create" | "read" | "update" | "delete";

type ActionCreator = (s: string) => { [K in CrudActions]: `${Uppercase<K>}_${Uppercase<typeof s>}` };

const createCrudActions: ActionCreator = <T extends string>(name: T) => {
  const record = name.toUpperCase();

  return {
    create: `CREATE_${record}`,
    read: `READ_${record}`,
    update: `UPDATE_${record}`,
    delete: `DELETE_${record}`,
  };
}

const postActions = createCrudActions("post");

But in this case the return type for `createCrudActions("post") is:

{
  create: `CREATE_${record}`;
  read: `READ_${record}`;
  update: `UPDATE_${record}`;
  delete: `DELETE_${record}`;
}

Whereas I'd like it to be:

{
  create: `CREATE_POST`;
  read: `READ_POST`;
  update: `UPDATE_POST`;
  delete: `DELETE_POST`;
}

TS Playground

I cajoled this to work with as const on all the properties and casting the toUpperCase() return value as Uppercase<T> ; at that point, though, it's not that much better than as Actions<T> . Technically this validates the transformation as correct, but the code this protects is unlikely to change and the code that consumes it is equally well-protected from type errors.

function createCrudActions<T extends string>(name: T) {
  const record = name.toUpperCase() as Uppercase<T>;

  return {
    create: `CREATE_${record}` as const,
    read: `READ_${record}` as const,
    update: `UPDATE_${record}` as const,
    delete: `DELETE_${record}` as const,
  };
}

Playground Link

Do you know why the overload is required in this case, as opposed to just specifying the return type of the function?

Consider this example:

type CrudActions = "create" | "read" | "update" | "delete";

type Actions<T extends string> = {
  [K in CrudActions]: `${Uppercase<K>}_${Uppercase<T>}`;
}

function createCrudActions<T extends string>(name: T):Actions<T> {
  /**
   * toUpperCase returns string instead of Uppercase<T>,
   * hence `CREATE_${record}` is now `CREATE_${string}` whereas you
   * want it to be `CREATE_${Uppercase<T>}`
   */
  const record = name.toUpperCase();

  return {
    create: `CREATE_${record}`,
    read: `READ_${record}`,
    update: `UPDATE_${record}`,
    delete: `DELETE_${record}`,
  } as const; // error
}

const postActions = createCrudActions("post").create;

It is clear why we have an error here. Because toUpperCase returns string whereas we want to operate on Uppercase<T> .

But why overloading works in this case? Function overloading acts bivariantly, it means that it compiles if overdload is assignable to function type signature or vice versa. Of course, we loose type strictness but gain flexibility.

See this exmaple, without overloading:


function createCrudActions<T extends string>(name: T) {

  const record = name.toUpperCase();

  const result = {
    create: `CREATE_${record}`,
    read: `READ_${record}`,
    update: `UPDATE_${record}`,
    delete: `DELETE_${record}`,
  } as const;

  return result
}

const result = createCrudActions("post");

type Check1<T extends string> = typeof result extends Actions<T> ? true : false

type Check2<T extends string> = Actions<T> extends typeof result ? true : false

type Result = [Check1<'post'>, Check2<'post'>]

Result is [false, true] . Since Result has at least one true , function overloading should work.

Version with overloading:

type CrudActions = "create" | "read" | "update" | "delete";

type Actions<T extends string> = {
  [K in CrudActions]: `${Uppercase<K>}_${Uppercase<T>}`;
}

function createCrudActions<T extends string>(name: T): Actions<T>
function createCrudActions<T extends string>(name: T) {
  const record = name.toUpperCase();

  return {
    create: `CREATE_${record}`,
    read: `READ_${record}`,
    update: `UPDATE_${record}`,
    delete: `DELETE_${record}`,
  } as const;
}

const result = createCrudActions("post");

Try to add extra underscore to Actions utility type:

type Actions<T extends string> = {
  [K in CrudActions]: `_${Uppercase<K>}_${Uppercase<T>}`;
}

Now, overloading is not assignable to function type signature because non of the types are not assignable to each other.

However, you can move upercasing to a separate function. In this way you will create only a little piece of unsafe code whereas your main function will be safe . When I say safe I mean: as much as TS allows it to be safe .

type CrudActions = "create" | "read" | "update" | "delete";

const uppercase = <T extends string>(str: T) => str.toUpperCase() as Uppercase<T>;

function createCrudActions<T extends string>(name: T) {
  const record = uppercase(name)

  return {
    create: `CREATE_${record}`,
    read: `READ_${record}`,
    update: `UPDATE_${record}`,
    delete: `DELETE_${record}`,
  } as const;
}

const result = createCrudActions("post").create

Now you don't even need Actions type because TS is able to infer all types on its own

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM