Typescript: Problems with Type System

TypeScript: problems with type system

var canvas = <HTMLCanvasElement> document.getElementById("mycanvas");
var ctx = canvas.getContext("2d");

or using dynamic lookup with the any type (no typechecking):

var canvas : any = document.getElementById("mycanvas");
var ctx = canvas.getContext("2d");

You can look at the different types in lib.d.ts.

Is the TypeScript type system too relaxed in this case?

The answer is yes, your code demonstrates an unsound behaviour of Typescript's type system, for exactly the reason you described in your question.

The answer is also no, you didn't "break" Typescript's type system, because this unsound behaviour is the intended behaviour. Typescript is not totally sound, and is not meant to be totally sound; quoting from the docs:

TypeScript’s type system allows certain operations that can’t be known at compile-time to be safe. When a type system has this property, it is said to not be “sound”. The places where TypeScript allows unsound behavior were carefully considered, and throughout this document we’ll explain where these happen and the motivating scenarios behind them.

Typescript's designers explicitly chose a balance between soundness and usefulness; a fully sound type system would not treat {bar: {x: number, y: number}} as a subtype of {bar: {x: number}}, but real Javascript code typically does pass around objects with more properties than the function will actually access, so the strictly sound behaviour would not be very useful to developers who write realistic Javascript code.

Issue with TypeScript typing

This is a known bug where excess property checking doesn't apply to nested types involving unions and intersections in the way that people expect. Excess property checking is kind of an add-on to the type system that only applies to object literals, so when it doesn't apply, things fall back to the structural subtyping rule where type {a: A, b: B} is a subtype of {a: A}, and so a value of the former type should be assignable to a variable of the latter type. You might want to head over to the issue in Github and give it a or explain your use case if you think it's more compelling than the ones already listed there. Hopefully there will be a fix someday.

Until then, though, I'm not sure what can be done. The type-level equivalent to excess property checks are so-called exact types, which don't exist in TypeScript as concrete types. There are ways to simulate them using generic helper functions and type inference... in your case it would look something like this:

type Exactify<T, K extends keyof any>
= T & { [P in Exclude<K, keyof T>]?: never };

type A<P, K extends keyof any=never> = {
p?: never,
q?: Partial<Exactify<P, K>>
}

type B<P, K extends keyof any=never> = {
p?: Partial<Exactify<P, K>>
q?: never
}

type C<P, K extends keyof any = never> = A<P, K> | B<P, K>

type KeyofKeyof<T> =
keyof T | { [K in keyof T]: T[K] extends object ? keyof T[K] : never }[keyof T];

const asC = <T extends C<{ a: number }, KeyofKeyof<T>>>(c: T) => c;

const c = asC({
p: {
a: 1,
b: 2 // <------ error
}
})

Yes, it's ugly. Not sure if it's worth it to you. The way it works is to say that C<P, K> is the same as your C<P> except that the P type is augmented to explicitly exclude all the extra properties in K. Then we use the helper function asC() to infer a type for K given the passed-in parameter. Since the keys in question are nested one level down, I needed a KeyofKeyof<T> type to extract keys from one level down (you can't do it for all levels without hitting circular types).

The desired error shows up now. Yay? I guess.

Anyway hope that helps. Good luck!

Condtional type is not working properly as return type

Well, it might not be what you need, but I found a solution.

The problem lies in the that whilst HasChild determines the return type, you end up with something akin too:

public get_value(): number {
if (this.child_name) {
return config[this.base_name][this.child_name]
}
return config[this.base_name]
}

Typescript determines that the two return statements have differing types (for example, SystemSettings | number), and hence you get the compiler error.

The only way I can see to resolve this is to create two implementations, one for base keys, and another for child keys, but which share a common interface.

Not that this adds some verbosity, but it appears to be type safe (assuming you annotate the type of the new BaseSetting/ChildSetting as I have done. Equally, you could leave it inferred, or use a type guard. I actually think you might have problems passing these settings wrappers around because you'll lose the specific type in doing so. It's possible you are going to be getting/setting values from a UI that has an arbitrary type (like a string) and that you'll therefore need to sanitise the value, which means adding something to convert the value before the Settings implementation can get/set the value. This seems to invalidate the need for a wrapper class that groks type in this way unless you can rely on JS's coercion.

Notes:

  1. I simplified the base_name and child_name props
  2. I added set_value to flesh out the use case
  3. It compiles, but I'm not sure its going to solve your wider problem ;-)
declare const $symbol: unique symbol;
interface NestedSetting {
[$symbol]?: never;
}

interface StartupSettings extends NestedSetting {
enabled: boolean;
delay: number;
}

interface Config {
add_while_paused: boolean;
min_chars: number;
note: string;
startup_settings: StartupSettings;
}

type BaseKeys = keyof Config;

type HasChild<BK extends BaseKeys, True, False> = Config[BK] extends NestedSetting
? True
: False;

type ChildKeysOf<BK extends BaseKeys> = HasChild<BK, keyof Config[BK], never>;

const config: Config = {
add_while_paused: true,
min_chars: 53,
note: "Hello World!",
startup_settings: { enabled: true, delay: 56 }
}

interface Setting<T> {
get_value(): T
set_value(value: T): void
}

class BaseSetting<BaseKey extends keyof Config> implements Setting<Config[BaseKey]> {

public constructor(public readonly base_name: BaseKey) { }

public get_value(): Config[BaseKey] {
return config[this.base_name]
}

set_value(value: Config[BaseKey]): void {
config[this.base_name] = value
}
}

class ChildSetting<BaseKey extends keyof Config, ChildKey extends ChildKeysOf<BaseKey>> implements Setting<Config[BaseKey][ChildKey]> {

public constructor(
public readonly base_name: BaseKey,
public readonly child_name: ChildKey
) { }

public get_value(): Config[BaseKey][ChildKey] {
return config[this.base_name][this.child_name]
}

set_value(value: Config[BaseKey][ChildKey]): void {
config[this.base_name][this.child_name] = value
}
}

const delay: Setting<number> = new ChildSetting("startup_settings", "delay")
delay.get_value()
delay.set_value(200)
const enabled: Setting<boolean> = new ChildSetting("startup_settings", "enabled")
enabled.get_value()
enabled.set_value(true)
const min_chars: Setting<number> = new BaseSetting("min_chars")
min_chars.get_value()
min_chars.set_value(10)

Return type errors for function chaining in TypeScript

Thanks to the comments I was able to solve it by using function overloading:

class MyElement {
element: HTMLElement;

constructor(element: HTMLElement) {
this.element = element;
}

html(html: string): MyElement;
html(html: true): string;

html(html: string | true = true): string | MyElement {
if (html === true) {
return this.element.innerHTML;
} else {
this.element.innerHTML = html;
return this;
}
}
style(prop: string, value: string) {
this.element.style.setProperty(prop, value);
return this;
}
}

var el = new MyElement(document.getElementById('myID'));
el.html('Lorem Ipsum').style('color', 'red');

Why doesn't Typescript detect that my return value doesn't match my interface?

The TypeScript compiler doesn't know anything about token other than the fact that it's going to be some string at runtime. And even if somehow knew the exact literal type that token would be, the standard library typing for the JSON.parse() method doesn't attempt to figure out what the shape of the returned value would be. Instead, it just returns the unsafe any type:

declare var JSON: JSON;
interface JSON {
parse(text: string, reviver?: (this: any, key: string, value: any) => any): any;
}

The any type is an "escape hatch" for the type system and essentially turns off type checking. So in const ret = JSON.parse(token), ret is of type any, and suddenly any crazy thing is permitted:

const ret: any = JSON.parse(token);
ret.cat.toUpperCase(); // no error
ret.dog.bark(); // no error
ret.cat.bat.rat.gnat.wombat.muskrat.meerkat.polecat.goat.stoat.meat; // no error

This isn't great, but that's the way it is. There's not much point trying to make the return type depend on the literal type of the input, since that would be very complicated and mostly useless, since the TypeScript compiler checks code well before runtime and so will almost never know very much about the input value.

There is also no built-in json type corresponding to just valid JSON.parse() outputs, although there's a longstanding open issue at microsoft/TypeScript#1897 asking for this.

It would be safer if JSON.parse() returned the unknown type instead of any, but the typings for JSON.parse() pre-dates the introduction of the safer unknown type in TypeScript 3.0... and changing it now would be a large breaking change.

If you have a safer alternative for the JSON.parse() call signature that you want to use instead, you can merge it in yourself. For example:

// merge it in
interface JSON {
parse(x: string): unknown;
}

Now you would get a compiler error if you return JSON.parse(token) directly:

async function getAuthToken(): Promise<Token | null> {
try {
const token = await AsyncStorage.getItem('userToken');
if (!token) { return null; }
const ret = JSON.parse(token);
return ret; // error! Type 'unknown' is not assignable to type 'Token | null'
} catch (error) { return null; }
}

Note that even without the any type, you can still suppress compiler errors by using a type assertion:

async function getAssertAuthToken(): Promise<Token | null> {
try {
const token = await AsyncStorage.getItem('userToken');
if (!token) { return null; }
const ret = JSON.parse(token);
return ret as Token; // okay
} catch (error) { return null; }
}

That's because TypeScript has assertions, not "cast"s... or at least the term "cast" is ambiguous enough to be confusing. A type assertion is like a "static cast" and just tells the compiler that it's okay to treat a value as having a particular type. Think of it as you giving the compiler information that it doesn't have. If you think the result of JSON.parse(token) will be a valid Token but the compiler doesn't know this, then you can write JSON.parse(token) as Token to tell it so. It is absolutely not a "runtime cast" that performs any kind of coercion at runtime. TypeScript's type system is erased from the emitted JavaScript, so any runtime coercion needs to involve JavaScript code. You can write String(x) to turn x into a string at runtime (and the compiler understands this) but x as string has no such effect; it's a prediction, not an operation.


So, if you don't use a type assertion and you don't use any, you will probably be in the position where the compiler complains that it can't be sure that the value is really a Token. At this point the only way forward would be to perform a thorough runtime check of the output in a way that the compiler understands to be narrowing from unknown all the way down to Token:

function hasPropOfType<T extends object, K extends PropertyKey, V>(
obj: T, key: K, valGuard: (x: any) => x is V): obj is T & Record<K, V> {
return valGuard((obj as any)[key]);
}
function isString(x: any): x is string { return typeof x === "string" };
function isNumber(x: any): x is number { return typeof x === "number" };
async function getAuthToken(): Promise<Token | null> {
try {
const token = await AsyncStorage.getItem('userToken');
if (!token) {
return null;
}
const ret = JSON.parse(token);
if (!ret) return null;
if (typeof ret !== "object") return null;
if (!hasPropOfType(ret, "created", isString)) return null;
if (!hasPropOfType(ret, "expires", isString)) return null;
if (!hasPropOfType(ret, "id", isString)) return null;
if (!hasPropOfType(ret, "lastUsed", isString)) return null;
if (!hasPropOfType(ret, "role", isString)) return null;
if (!hasPropOfType(ret, "updated", isString)) return null;
if (!hasPropOfType(ret, "user", isString)) return null;
if (!hasPropOfType(ret, "cat", isNumber)) return null;
return ret; // okay
} catch (error) { return null; }
}

Here we've written some custom type guard functions and used them a bunch of times until the compiler is convinced on its own that ret is of type Token. If you're interested in this sort of approach, you might want to look at Typescript check object by type or interface at runtime with typeguards in 2020+


So, there you go. TypeScript doesn't complain that cat doesn't exist on JSON.parse(token) as Token because:

  • it doesn't know anything about token other than it's a string
  • JSON.parse()'s return type is any, which makes the compiler accept any crazy thing
  • even if you make it return something safer like unknown, the type assertion as Token serves to suppress errors, not generate them

Playground link to code

Why is TypeScript unable to infer the type properly?

Type guards generally work on the field of variable they are applied to, so if you narrow test.type, the narrowing generally applies to just type not to test. The one exception to this is if the type of test is a discriminated union and test is a the discriminant field.

For the property to be a discriminant property, it must fulfill some requirements:

  1. The property is a literal type as outlined here Discriminated union types
  2. The a property of a union type to be a discriminant property if it has a union type containing at least one unit type and no instantiable types as outlined here Allow non-unit types in union discriminants

As for the second example, there just isn't support for nested discriminated unions. There is a proposal to support this but it has been open for 3 years, without much movement.

You can adjust your model to conform to discriminated union rules, or, as a work around, you can use custom type guards:

interface AType {
type: 'A'
message: string
}

interface BCType {
type: 'B' | 'C'
}

type Test = AType | BCType

function doSomething(test: Test) {
if (!isBC(test)) {
test.message
}
}

function isBC(o: any): o is BCType {
return o?.type != "B" && o?.type != "C"
}

Playground Link

Typescript: Having problems with very complex generic type mapping of records

Took some finicking and trial-and-error, but I got something that seems to work partially:

type ActionResult<A extends Action<any>['name']> = { [action in Action<any> as A]: action extends { name: A, type?: { transform: (...args: any) => infer R } } ? R : never; }[A];

export default function useTransformer<IN, E extends keyof IN, M extends Record<keyof M, Action<E>>>(state: IN, mappings: M) {
type N = keyof M;
return {
form: {} as {
[K in E | N]: K extends N ? ActionResult<M[K]['name']> : (K extends keyof IN ? IN[K] : never);
},
};
}

// Results in form to be of type:
const form: {
range: Date[];
dateFrom: string;
dateTo: string;
simpleMap: string;
a: string;
b: string;
c: number;
};

Two small (intertwined) problems: it doesn't omit fields used in transformers, and the E generic type is "useless" (because I'd use to omit the fields, but TS can't narrow it enough).

You can fix the first problem (and therefore the 2nd) by creating a type like ActionConsumes to "calculate" which fields are consumed, e.g. something like:

interface ActionConsumesFields {
dateRange: 'from' | 'to';
test: 'field';
}
type ActionConsumes<A extends Action<any>> = {
[name in keyof ActionConsumesFields]:
A extends { name: name } & { [key in ActionConsumesFields[name]]: any } ? A[ActionConsumesFields[name]] : never }[keyof ActionConsumesFields];

export default function useTransformer<IN, E extends keyof IN, M extends Record<keyof M, Action<E>>>(state: IN, mappings: M) {
type N = keyof M;
type Consumed = ActionConsumes<M[keyof M]>;
return {
form: {} as {
[K in Exclude<E, Consumed> | N]: K extends N ? ActionResult<M[K]['name']> : (K extends keyof IN ? IN[K] : never);
},
};
}

// Resulting type of form
// (notice how the dateFrom/dateTo/a fields are missing)
const form: {
range: Date[];
simpleMap: string;
b: string;
c: number;
};

(see addition of the ActionConsumes stuff)

Give me a few minutes and I might figure out in tricking TS to auto-calculate ActionConsumerFields, but no promises there.


Actually got the "auto-detect fields" to work:

const _ACS = Symbol();
type ActionConsumesFields = {
[action in Action<typeof _ACS> as action['name']]: keyof { [key in keyof action as action[key] extends typeof _ACS ? key : never]: 1; };
};

// Resulting type of ActionConsumesFields:
type ActionConsumesFields = {
dateRange: "from" | "to";
test: "field";
};


Related Topics



Leave a reply



Submit