Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript generic type does not enforce type safety

Tags:

typescript

I'm trying to generate a generic function that allow me to generate a strongly typed setter for a given type, with a callback - e.g:

interface Foo {
   a: number,
   b: string
}

magick('a', 43) => {} // Should work
magick('a', '43') => {} // Should fail

I've implemented a generic function that does this - and it works. But however if I try to copy that function type safety is not enforced (or more likely I'm misunderstanding typescript!)

interface Test {
  a: number;
  b: string;
}

interface Test2 {
  a2: boolean;
  b2: '+' | '-';
}

const testVal: Test = {
  a: 42,
  b: 'test',
};

type Demo<T> = <K extends keyof T> (key: K, val: T[K]) => void

const implDemo: Demo<Test> = (key, val) => {
  testVal[key] = val;
};

Firstly - the function is working as how I want it:

/* prints: {a: 10, b: "test"} - correct  */
implDemo('a', 10); console.log(testVal); 

/* Fails as expected - type safety - a should be number */
implDemo('a', 'text'); 

But why is this following possible? How Can Demo<Test2> be assignable to Demo<Test>

/* Create a pointer to implDemo - but with WRONG type!!! */
const implDemo2: Demo<Test2> = implDemo; 
implDemo2('a2', true); 
console.log(testVal); 
/* prints: {a: 10, b: "test", a2: true} - we just violated out type!!!! */

What we just did above is the same as doing:

testVal['a2'] = true; /* Doesn't work - which it shouldn't! */

Here is another simply type, where type safety is actually enforced

type Demo2<T> = (val: T) => void;
const another: Demo2<string> = (val) => {};
/* This fails - as expected */
const another2: Demo2<number> = another;

Is this a bug in typescript - or am I misunderstanding something? My suspicion is that type Demo<T> = <K extends keyof T> is the culprit, but I simply don't understand how I'm allowed to "hack" the typesystem this way.

like image 584
birkmose Avatar asked Apr 09 '19 11:04

birkmose


People also ask

Does TypeScript enforce types?

Bookmark this question. Show activity on this post. I had an impression that TypeScript allowed you to take a valid JavaScript program and "enforce" types by making a few key symbols type-safe.

Is TypeScript type safe?

It seemed like TypeScript was failing to live up to its potential regarding type safety. Fortunately, with some non-default configuration and some discipline, it is possible to get a substantial degree of both compile-time and run-time type safety from TypeScript.

How do you pass a generic type as parameter TypeScript?

Assigning Generic ParametersBy passing in the type with the <number> code, you are explicitly letting TypeScript know that you want the generic type parameter T of the identity function to be of type number . This will enforce the number type as the argument and the return value.

How do you define a generic type in TypeScript?

Generics allow creating 'type variables' which can be used to create classes, functions & type aliases that don't need to explicitly define the types that they use. Generics makes it easier to write reusable code.


1 Answers

I'd say this is a compiler bug. I'm not sure if it has been reported yet; so far I haven't found anything by searching, but that doesn't mean it isn't there. EDIT: I've filed an issue about this behavior; we'll see what happens. UPDATE: looks like this might be fixed for TS3.5 TS3.6 TS3.7... who knows ๐Ÿ˜…!

Here's a reproduction:

interface A { a: number; }
interface B { b: string; }

type Demo<T> = <K extends keyof T> (key: K, val: T[K]) => void

// Demo<A> should not be assignable to Demo<B>, but it is?!
declare const implDemoA: Demo<A>;
const implDemoB: Demo<B> = implDemoA; // no error!? ๐Ÿ˜•

// Note that we can manually make a concrete type DemoA and DemoB:
type DemoA = <K extends keyof A>(key: K, val: A[K]) => void;
type DemoB = <K extends keyof B>(key: K, val: B[K]) => void;

type MutuallyAssignable<T extends U, U extends V, V=T> = true;
// the compiler agrees that DemoA and Demo<A> are mutually assignable
declare const testAWitness: MutuallyAssignable<Demo<A>, DemoA>; // okay
// the compiler agrees that DemoB and Demo<B> are mutually assignable
declare const testBWitness: MutuallyAssignable<Demo<B>, DemoB>; // okay

// And the compiler *correctly* sees that DemoA is not assignable to DemoB
declare const implDemoAConcrete: DemoA;
const implDemoBConcrete: DemoB = implDemoAConcrete; // error as expected
//    ~~~~~~~~~~~~~~~~~ <-- Type 'DemoA' is not assignable to type 'DemoB'.

๐Ÿ”—Link to code๐Ÿ‘จโ€๐Ÿ’ป

You can see that DemoA and Demo<A> are basically the same type (they are mutually assignable, meaning a value of one type can be assigned to a variable of the other). And DemoB and Demo<B> are also the same type. And the compiler does understand that DemoA is not assignable to DemoB.

But the compiler thinks that Demo<A> is assignable to Demo<B>, which is your problem. It's as if the compiler "forgets" what T in Demo<T> is.


If you really need to work around this for now you might want to brand Demo<T> with something that will "remember" what T is, but doesn't stop you from assigning your implementation to it:

// workaround    
type Demo<T> = (<K extends keyof T> (key: K, val: T[K]) => void) & {__brand?: T};

const implDemoA: Demo<A> = (key, val) => {} // no error
const implDemoB: Demo<B> = implDemoA; // error here as expected

Okay, hope that helps; good luck!

like image 142
jcalz Avatar answered Oct 02 '22 12:10

jcalz