2

Is it possible to enforce a specific type at initialization of a variable, but then extract the literal value of it to a literal type, at a later point? See Below:

type Person = { name: string; }

//this needs to be typed. to enforce structure
const john: Person = { 
    name: 'John'
}; //'as const' doesn't seem to work here because it's already typed

//I'm wanting the literal value of john { name: 'John' } instead of { name: string }
type John = typeof john; //as const here throws an error
2
  • No, it's too late to recover information you've thrown away with an explicit annotation. Why don't you do it in the reverse order? If that meets your needs I'll write up an answer; if it doesn't, then please edit the code in the question to show the failed use case. Commented Sep 21, 2021 at 19:44
  • Thanks @jcalz, that could possibly work. I think with that direction we would lose excess property validation, and the type validation errors would be slightly more round about, but looks like we could at least accomplish the job. Commented Sep 21, 2021 at 20:03

1 Answer 1

2

No; when you annotate a variable with a non-union type like Person, the compiler does not narrow to something more specific upon assignment. So in your code, the type of john is Person, and any narrower type it might have inferred from the initializing value has been thrown away forever. It is too late to retrieve it.

So if you want to capture the narrow type of the initial value, you will need to do so before assigning it to the annotated variable. For example:

type Person = { name: string; }

const johnInitializer = {
    name: 'John'
} as const;

type John = typeof johnInitializer;
/* type John = {
    readonly name: "John";
} */

const john: Person = johnInitializer;

The assignment at the end enforces that the structure of johnInitializer is compatible with Person. After this you can use john anywhere you would want to use a Person.


Unless you have a reason why you want john to be as wide as Person (e.g., you want to reassign the name property or something), you can skip the assignment and just use the initial value:

const john = { name: "John" } as const;

type John = typeof john;
/* type John = {
    readonly name: "John";
} */

Presumably you will be using john somewhere that expects a Person.

// later...
acceptPerson(john); // okay

The fact that this is accepted means that john conforms to the Person structure. If you made a mistake, then you'd get an error at the usage site:

const jhon = { naem: "Jhon" } as const; // oops

// later ...
acceptPerson(jhon); // error!

You can think of that assignment as just a fail-fast check so that you catch the error closer to the declaration. There are other ways to do this; for example, a generic identity helper function:

const checkType = <T,>() => <U extends T>(u: U) => u;
const personCheck = checkType<Person>();

The function personCheck will accept a value of a type assignable to Person and return it, without widening the value to Person:

const john = personCheck({ name: "John" } as const); // okay    

type John = typeof john;
/* type John = {
    readonly name: "John";
} */

const jhon = personCheck({ naem: "Jhon" } as const); // error!

Finally, you mentioned that using a direct assignment would perform excess property checking whereas these other indirect methods would not. And that's true, you lose these excess property checks:

const johnnyAppleseed = personCheck(
    { name: "Johnny", appleSeeds: true }); // okay

Personally I try to avoid code that cares about excess properties, since they are a fact of structural typing. Still, if you care, you can change the helper function to be stricter:

const checkExactType = <T,>() => <U extends T>(
    u: { [K in keyof U]: K extends keyof T ? U[K] : never }
) => u;
const personCheck = checkExactType<Person>();

Now personCheck will only accept values of types U which are both assignable to T and which have no extra properties (technically it requires that any such extra properties be of type never):

const john = personCheck({ name: "John" } as const); // okay    

const jhon = personCheck({ naem: "Jhon" } as const); // error!

const johnnyAppleseed = personCheck(
    { name: "Johnny", appleSeeds: true }); // error!

So now you have narrowed types with excess property checking as well.

Playground link to code

Sign up to request clarification or add additional context in comments.

3 Comments

This is helpful. Using the personCheck concept to initialize an object is a great solution. I haven't seen that in practice before, but It would allow me to enforce the structure while also retaining the literal value. Perhaps this is better in another question, but how expensive is it to type large objects with presumably large strings in some of their values as consts?
expensive... for the compiler? I'm not sure how "large" these strings are but I expect they'd have to be pretty huge to have major compiler issues. Without a minimal reproducible example I'm not sure how to answer that, though. Even with one I'm not necessarily able to speak that authoritatively about compiler performance.
Please update answer with satisfies operator :)

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.