Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

TypeScript template literal types and infer works together somehow?

Tags:

typescript

Can someone explain me the code below, it is not like I have any problem here. But, I wanna know why is it working.

How come this CREATETYPE knows that namespace is undefined or not, thus creates the correct type ?

type CREATETYPE<K extends string, NS extends string> = NS extends `${infer NS}` ? `${NS}/${K}` : K;

function generateDynamicTypes<T extends string, NS extends string>(keys: T[], namespace?: NS): { [K in T]: CREATETYPE<K, NS> } {
  const namespacePrefix: string = namespace ? `${namespace}/` : '';

  return keys.reduce((res, key) => {
    res[key] = `${namespacePrefix}${key}`;
    return res;
  }, Object.create(null));
}

like image 408
Apak Avatar asked Oct 24 '25 04:10

Apak


1 Answers

The short answer is the compiler cannot infer from string to a template literal type. I will unpack that in the long answer below:


The CREATETYPE type alias is using template literal types as introduced in TypeScript 4.1. The code here is a bit weird and not the way I'd approach it. And the particular behavior you're asking about doesn't seem to be explicitly documented, but I'll try to explain what's happening.


Note that if you call generateDynamicTypes() without a namespace argument, the compiler has no value from which to infer the NS type parameter. As such, the inference fails, and the compiler defaults to inferring it as its constraint. In this case, it is string:

generateDynamicTypes(["a", "b"])
// function generateDynamicTypes<"a" | "b", string>(...)

On the other hand, if you specify a string literal value for namespace, the compiler will infer NS to be the string literal type of that value, so:

generateDynamicTypes(["a", "b"], "ns")
// function generateDynamicTypes<"a" | "b", "ns">(...)

This is one place where I probably wouldn't write the code this way, because if namespace really is a string without being a known string literal, the output type will be incorrect:

const oops = generateDynamicTypes(["a", "b"], "oopsie".toUpperCase());
/* const oops: {
    a: "a";
    b: "b";
} */

The type of "oopsie".toUpperCase() is just string according to the compiler, not the actual literal value "OOPSIE". So the compiler says the output property values will just be "a" and "b" whereas you'll get "OOPSIE/a" and "OOPSIE/b" at runtime. This mismatch is an edge case and maybe nobody cares about it? But moving on.


So your question becomes: why does CREATETYPE<K, NS>, defined as

type CREATETYPE<K extends string, NS extends string> =
  NS extends `${infer NS}` ? `${NS}/${K}` : K;

take the "true" branch of the conditional type when NS is a string literal like "ns", but the "false" branch when NS is just string, as in:

type PrefixedA = CREATETYPE<"a", "ns"> // "ns/a"
type JustA = CREATETYPE<"a", string> // "a"

Before we go on, I'm going to change the definition to

type CREATETYPE<K extends string, NS extends string> =
  NS extends `${infer NS2}` ? `${NS2}/${K}` : K;

which is identical in behavior, but highlights the fact that infer NS2 introduces a new type parameter; in the original code, this was shadowing the previous value of NS. I personally wouldn't shadow type parameters for this case.


If we look at the pull request introducing template literal types, microsoft/TypeScript#40336, there is this in the description:

Type inference supports inferring from a string literal type to a template literal type. [...details about matching elided...] Some examples:

 type MatchPair<S extends string> = S extends `[${infer A},${infer B}]` ? [A, B] : unknown;
 type T20 = MatchPair<'[1,2]'>;  // ['1', '2']

So NS extends `${infer NS2}` ? ... should try to match the whole input string as the inferred value of NS2. That means NS2, if inferred, will just be equal to NS, and the whole thing looks like a no-op. And indeed, when NS is a string literal type like "a", this is what happens.

So again, why does string extends `${infer NS2}` ? ... not work? Why doesn't NS2 get inferred as string? Here is where the documentation isn't explicit. Again, it says:

Type inference supports inferring from a string literal type to a template literal type.

The fact that the documentation makes sure to say that inference from a string literal type works seems to imply that inference from the non-literal string type won't work... but it doesn't say this in so many words. You can look at the relevant line in the PR diff (you need to view large diffs to see this) and confirm, however, that the compiler only tries to infer into a template literal type if the source type is a string literal.

So there you go!


If I were going to write this code, I'd probably first change it to not rely on undocumented behavior. Perhaps like this:

type CREATETYPE2<K extends string, NS extends string> =
  string extends NS ? `${NS}/${K}` : K;

And furthermore, I'd tease apart the undefined from the string case, maybe like this:

function generateDynamicTypes2<
  T extends string, NS extends string | undefined = undefined
>(
  keys: T[],
  namespace?: NS
): { [K in T]: NS extends undefined ? K : `${NS}/${K}` } {
  // same impl
}

generateDynamicTypes2(["a"]) // {a: "a"}
generateDynamicTypes2(["a"], "ns") // {a: "ns"/ a}
generateDynamicTypes2(["a"], "oopsie".toUpperCase()); // { a: `${string}/a` }

Looks reasonable to me now. The type ${string}/a just means "I only know this string ends with "/a"", which corresponds pretty well to what is coming out of that function in the case where namespace is only known to be string.

Playground link to code

like image 188
jcalz Avatar answered Oct 25 '25 17:10

jcalz