In order to guarantee type-safety, is the "as const" assertion the only solution here?

Viewed 97

I have the following objectify implementation:

type MappedObject<T extends readonly string[]> = {
  [key in T[number]]: number;
};

function objectify<T extends readonly string[]>(array: T): MappedObject<T> {
  return array.reduce(
    (acc, term, index) => ({ ...acc, [term]: index }),
    {}
  ) as MappedObject<T>;
}

Playground link.

With that implementation, when I call objectify asserting the first passed argument with as const, it returns a type-safe object, as you can see in the following example:

const obj = objectify(['a', 'b', 'c'] as const); // Notice the `as const` assertion.

obj.a;
obj.b;
obj.c;
obj.d; // Error! My code is safe. :)

Example of this "safe" behavior in the playground.

Sadly, however, when I remove that as const assertion, that type-safety goes away, which turns my code error-prone. Because of that "unsafe" returned type, I can even access properties that don't even exist in the returned object, as exemplified (obj.d) in the last line of the following example:

const obj = objectify(['a', 'b', 'c']); // No more `as const` assertion.

obj.a;
obj.b;
obj.c;
obj.d; // No errors! My code is error prone. :(

Example of this "unsafe" behavior in the playground.


The question is: Is using the as const assertion the only way to guarantee that type-safety? There is another way of achieve this same "safe" behavior?

My goal is to fully guarantee the code safety in the function implementation, avoiding any assertion when I invoke the function. I want to make the following code safe by only changing the function implementation, and not its calls.

// What I can do:
const obj = objectify(['a', 'b', 'c'] as const);

// What I want to be able to do:
const obj = objectify(['a', 'b', 'c']);

///// `obj` would be "safe" here.
1 Answers

I feel your pain here. Ideally there would be a feature which allows you to annotate a function so that it treats certain arguments as const without requiring the caller to do it. I've asked for this in microsoft/TypeScript#30680; if you want to see this happen, you might want to go there and give it a , but for now it's not a feature.

Luckily there are ways to write function signatures that give the compiler hints to guide inference like this; it's just that they are hard to comprehend and seem like bizarre magic instead of straightforward communication of the developer's intent.

In the specific case of inferring string literal types, one way to get this to happen is to use a type parameter constrained to string (i.e., S extends string) instead of string itself (see microsoft/TypeScript#10676):

function objectify<T extends readonly S[], S extends string>(array: T): MappedObject<T> {
  return array.reduce(
    (acc, term, index) => ({ ...acc, [term]: index }),
    {}
  ) as MappedObject<T>;
}

This looks kind of useless, especially because S will likely be inferred as string. But crucially, this causes the type of T to be inferred as Array<"a" | "b" | "c"> instead of Array<string>:

const stillSafeObj = objectify(['a', 'b', 'c']); // No more `as const` assertion.
stillSafeObj.a;
stillSafeObj.b;
stillSafeObj.c;
stillSafeObj.d; // Error! My code is safe. :)

It works. Hooray for bizarre magic!! ‍♂️✨


Okay, hope that helps; good luck!

Playground link to code

Related