'How to use an object with const assertion (as const) as an unique symbol for another type key

I have a list of routes :

const N: any = "N"; // get const from non typed lib for example or typed as "string"
const PREFIX = `Route_${N}` as const;
const Route = {
  Route1: `${PREFIX}_1`,
  Route2: `${PREFIX}_2`
} as const; // I use object litteral because enum doesn't allow computed value

I would like to type params for each route:

type ParamsList = {
  [Route.Route1]: undefined;
  [Route.Route2]: { value: string };
};

But it's not allowed with this TypeScript error:

A computed property name in a type literal must refer to an expression whose type is a literal type or a 'unique symbol' type.ts(1170)

How can I use my route name as unique symbol key for my param list type ? const assertions (as const) should not make object keys unique ?

Thanks for your help.



Solution 1:[1]

New Answer

Let's unpack the error message:

A computed property name in a type literal must refer to an expression whose type is a literal type is a literal type or a 'unique symbol' type.ts(1170)

"A computed property name" refers to the properties in your final ParamsList type - ( e.g. Route.Route1 in [Route.Route1]: undefined;).

So typescript is saying Route.Route1 must be an expression that ultimately evaluates to a "literal type" (e.g. a specific string like "Route1_N_1", or a specific number like 42), or a Symbol (not important here, but if you're curious, check the Symbol docs)

Here's what type Route.Route1 currently evaluates to:

`Route_${any}_1`

This known as a "template literal type" (see docs) - which is not the same thing as a literal string (e.g. "Route_N_1"). It sort of makes sense that TS would require this, too - it puts the compiler in a situation where it would be possible to have two (or more) different types defined for the same property on ParamsList, if there were multiple template literal types that could expand in overlapping ways.

So if you want to fix the problem, you need to somehow cause typescript to be able to evaluate Route.Route1 to a specific string, not a generic one.

The docs say that template literal types "have the ability to expand into many strings via unions," which is what you want. In order to trigger this, you must make sure that all the template positions (e.g. ${N} in Route_${N} get fed specific literal string types (e.g. "foo" or "bar", not things like any or string).

Here's one way to do this:

// const N: any = "N"; // original type "any" <--this won't work
const N = "N" as const; // new type "N" <-- this will

const PREFIX = `Route_${N}` as const; // new type: "Route_N"; original type: "Route_${any}"
const Route = {
  Route1: `${PREFIX}_1`, // new type: "Route_N_1"; original type: "Route_${any}_1"
  Route2: `${PREFIX}_2` // new type: "Route_N_2"; original type: "Route_${any}_2"
} as const;

type ParamsList = {
  [Route.Route1]: undefined;
  [Route.Route2]: { value: string };
};

See this playground example.

Update: How to provide a string literal type to something if the library you're building with doesn't provide it

In the above example, we're manually creating the N variable, so it's easy enough to provide a string literal type with as const assertion.

However, if N were actually something imported from another library, that had a type of any, we would need to somehow provide this type ourselves. As you pointed out in the comments, one way to do this would be to add an as "MyString" assertion to the end.

Another way would be to use module augmentation combined with declaration merging. In the specific example you provided in the comments, you were doing this:

import { StyleSheet, Text, View, NativeModules } from "react-native";
const { NativeNaviagtion } = NativeModules;
// NativeNavigation.Page1 <-- this has type "any"

You can "augment" the NativeModules type provided by the "react-native" module so that NativeModules.NativeNavigation.Page1 has a literal string type "Page1" by writing:

declare module "react-native" {
  interface NativeModulesStatic {
    NativeNaviagtion: { Page1: "Page1" };
  }
}

See this codesandbox example and this answer for a similar problem in a different context.

Old Answer

Your original example (without the additional layer of indirection with const N: any = "N") will work in TypeScript 4.1+, but not in TypeScript 4.0-. That's because TS added the Template Literal Type feature in 4.1.

Before 4.1, the Route const would have this type:

const Route: {
    readonly Route1: string;
    readonly Route2: string;
}

After 4.1, the Route const has this type:

const Route: {
    readonly Route1: "Route_1";
    readonly Route2: "Route_2";
}

So with 4.0- versions of typescript, this...

type ParamsList = {
  [Route.Route1]: undefined;
  [Route.Route2]: { value: string };
};

...would be interpreted as this...

type ParamsList = {
  [string]: undefined;
  [string]: { value: string };
};

...which is ambiguous, and throws that error. In 4.1+, that gets correctly interpreted as:

type ParamsList = {
    Route_1: undefined;
    Route_2: {
        value: string;
    };
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1