ZZB
ZZB

Reputation: 810

Defining a value with type constraints while keeping narrow type of the value in typescript

I would like to declare a value that extends specific type and keeps its narrow type at the same time.

Is there a way to achieve this without calling a function?

const stringRecord : <T extends Record<string, string>>(x: T)=>T= (x) => x;


//Define value that extends Record<string, string> without calling a function 
const abc = stringRecord({
      a: "a",
      b: "b",
      c: "c"
  });


//Type should remain {a: string, b: string, c: string}
type ABC = typeof abc;

Link to playground

Upvotes: 1

Views: 53

Answers (1)

jcalz
jcalz

Reputation: 328342

No, there is currently (as of TypeScript 4.4) no type operator which checks that a value is assignable to a given (non-union) type without also widening it to that type. There is a longstanding open feature request for such an operator at microsoft/TypeScript#7481. If you want to see this happen you might go to that issue and give it a 👍 or describe your use case and why the existing workarounds aren't sufficient; but I don't think it's likely that a single additional voice there will have much effect.

For now there are, unfortunately, only workarounds.

The remainder of this answer will discuss some of them briefly; although since they are not acceptable to you, you may ignore it. Consider the discussion academic, or possibly helpful to future readers.


The workaround from your question, using a helper function, is a good one in my opinion. It has some minimal runtime impact, but it's not too cumbersome to use.


If you don't want any runtime impact at all, you could write some type functions to do something similar but purely in the type system:

const abc = {
    a: "a",
    b: "b",
    c: "c"
};

type ABC = typeof abc;
type Extends<T extends U, U> = void;
type TestABC = Extends<ABC, Record<string, string>>; // okay

The Extends<T, U> type function doesn't evaluate to anything useful (it's just void), but it will throw a compiler warning if T does not extend U. Observe:

const abc = {
    a: "a",
    b: 123, // <-- not a string
    c: "c"
};

type ABC = typeof abc;
type Extends<T extends U, U> = void;
type TestABC = Extends<ABC, Record<string, string>>; // error!
// ------------------> ~~~
// Property 'b' is incompatible 

And all the extra stuff gets erased at runtime.


Finally, the simplest workaround is just to ignore the constraint entirely and rely on something elsewhere to throw a compiler error. That is, if you write just

const abc = {
    a: "a",
    b: "b",
    c: "c"
}; 

and move on, presumably somewhere else in your code base you will use abc in a place that expects a Record<string, string>. Perhaps by passing it to some function you actually want to use:

declare function doSomethingLater(x: Record<string, string>): void;

If it works with no compiler warning, then great:

doSomethingLater(abc); // okay

If not, the warning will tell you what was wrong with abc:

doSomethingLater(abc); // error!
/* Argument of type '{ a: string; b: number; c: string; }' is not 
assignable to parameter of type 'Record<string, string>' */

If no code in your code base complains when abc is not assignable to Record<string, string>, then you might want to step back and consider why that is. Maybe you don't actually need such a constraint because the code doesn't care. But if the absence of a warning doesn't imply that everything's fine, then you might need to fall back to one of the previous workarounds.


Playground link to code

Upvotes: 1

Related Questions