Grumbling on TypeScript
I’ve been writing TypeScript since just before v1.8, which brought the killer feature JavaScript in TypeScript Compilation. Right now the latest version is v3.8.3 and there’s been many great features added over the last few years. Staying on top of new features, how they work, and even changes to older ways of working can be time consuming and at times frustrating.
I really like TypeScript, its similarity to C#, and the fact it taught me about ES6 before I knew what ES6 was. But, sometimes TypeScript will give me some head-collides-with-wall moments and have me frustrated with code that runs fine but fails type checking.
Missing Type Definitions won’t slow me down
I was recently asked whether I have problems with missing Type Definitions, and I think this was more a problem in the past but not so much now. Often types are bundled with the npm package you’re importing and if not DefinitivelyTyped is huge and exposed via the @types organisation on npm. Who remembers typings? Who remembers tsd?
In my more recent experience only smaller projects are missing type definitions and a simple work around is to just import them without types by supplying your own definition as a one-liner;
// react-animation-frame.d.ts
declare module 'react-animation-frame';
Even if you really care about type checking, it’s unlikely you’re going to be using the library’s entire api, so you can implement your own definition only for the parts of the api you are using. I use one of these approaches every time, such that I don’t even use other packages that generate definitions from javascript or from JSDocs if I come across missing definitions.
TSX breaks Generic Arrow functions
In a .tsx
the syntax for generic functions does not match the syntax for generic arrow functions. In .ts
this problem does not exist. This issue will not be fixed, there’s a discussion on Stack Overflow also.
// .ts
function foo<T>(x: T): T { return x; } // ok
const foo = <T>(x: T) => x; // ok
// .tsx
function foo<T>(x: T): T { return x; } // ok
const foo = <T>(x: T) => x; // ERROR: JSX element 'T' has no corresponding closing tag
const foo = <T extends unknown>(x: T) => x; // ok
const foo = <T,>(x: T) => x; // ok
Whacky type inference on Generics vs Concrete
I wrapped up some of @reduxjs/toolkit
helpers inside some helpers of my own for Digital Icebreakers and and found that nesting a specific generic function call resulted in a rather nasty compiler message. The issue occurs when a generic type is passed, but does not occur if the generic type is first wrapped in a concrete type.
import { createAction, ActionCreatorWithPayload } from '@reduxjs/toolkit';
interface Wrapper<T> {
inner: T
};
const a = <T extends {}>(name: string) => createAction<T>(name);
const b = <T extends {}>(name: string) => createAction<Wrapper<T>>(name);
const c = (name: string) => createAction<number>(name);
const d = <T extends {}>(action: ActionCreatorWithPayload<T>) => {};
const e = <T extends {}>() => {
d(a<T>("x")); // ERROR - see below
d(b<T>("x")); // ok
d(c("x")); // ok
};
The error looks like this;
Argument of type 'IsAny<T, ActionCreatorWithPayload<any, string>, IsUnknown<T, ActionCreatorWithNonInferrablePayload<string>, IfVoid<...>>>' is not assignable to parameter of type 'ActionCreatorWithPayload<T, string>'.
Type 'ActionCreatorWithPayload<any, string> | IsUnknown<T, ActionCreatorWithNonInferrablePayload<string>, IfVoid<...>>' is not assignable to type 'ActionCreatorWithPayload<T, string>'.
Type 'IsUnknown<T, ActionCreatorWithNonInferrablePayload<string>, IfVoid<T, ActionCreatorWithoutPayload<string>, ActionCreatorWithOptionalPayload<...>>>' is not assignable to type 'ActionCreatorWithPayload<T, string>'.
Type 'IfVoid<T, ActionCreatorWithoutPayload<string>, ActionCreatorWithOptionalPayload<T, string>> | IsAny<...>' is not assignable to type 'ActionCreatorWithPayload<T, string>'.
Type 'IsAny<T, IfVoid<T, ActionCreatorWithoutPayload<string>, ActionCreatorWithOptionalPayload<T, string>>, ActionCreatorWithNonInferrablePayload<...>>' is not assignable to type 'ActionCreatorWithPayload<T, string>'.
Type 'ActionCreatorWithNonInferrablePayload<string> | IfVoid<T, ActionCreatorWithoutPayload<string>, ActionCreatorWithOptionalPayload<...>>' is not assignable to type 'ActionCreatorWithPayload<T, string>'.
Type 'ActionCreatorWithNonInferrablePayload<string>' is not assignable to type 'ActionCreatorWithPayload<T, string>'.
Types of property 'match' are incompatible.
Type '(action: Action<unknown>) => action is { payload: unknown; type: string; }' is not assignable to type '(action: Action<unknown>) => action is { payload: T; type: string; }'.
Type predicate 'action is { payload: unknown; type: string; }' is not assignable to 'action is { payload: T; type: string; }'.
Type '{ payload: unknown; type: string; }' is not assignable to type '{ payload: T; type: string; }'.
Types of property 'payload' are incompatible.
Type 'unknown' is not assignable to type 'T'.
'unknown' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint '{}'.
This occurs only when TypeScript 2.6’s –strictFunctionTypes is on, and a similar issue is described on Stack Overflow. This type of error message from a compiler is pretty intimidating, and vscode makes it a bit more difficult to understand the hierarchy present in the message because it wraps it at 90 characters. Frustratingly the same error will be slightly different if --strictNullChecks
or --strict
is also enabled;
Argument of type 'IsAny<T, ActionCreatorWithPayload<any, string>, IsUnknown<T, ActionCreatorWithNonInferrablePayload<string>, IfVoid<...>>>' is not assignable to parameter of type 'ActionCreatorWithPayload<{}, string>'.
Type 'ActionCreatorWithPayload<any, string> | IsUnknown<T, ActionCreatorWithNonInferrablePayload<string>, IfVoid<...>>' is not assignable to type 'ActionCreatorWithPayload<{}, string>'.
Type 'IsUnknown<T, ActionCreatorWithNonInferrablePayload<string>, IfVoid<T, ActionCreatorWithoutPayload<string>, IfMaybeUndefined<...>>>' is not assignable to type 'ActionCreatorWithPayload<{}, string>'.
Type 'IfVoid<T, ActionCreatorWithoutPayload<string>, IfMaybeUndefined<T, ActionCreatorWithOptionalPayload<T, string>, ActionCreatorWithPayload<...>>> | IsAny<...>' is not assignable to type 'ActionCreatorWithPayload<{}, string>'.
Type 'IfVoid<T, ActionCreatorWithoutPayload<string>, IfMaybeUndefined<T, ActionCreatorWithOptionalPayload<T, string>, ActionCreatorWithPayload<...>>>' is not assignable to type 'ActionCreatorWithPayload<{}, string>'.
Type 'ActionCreatorWithoutPayload<string> | IfMaybeUndefined<T, ActionCreatorWithOptionalPayload<T, string>, ActionCreatorWithPayload<T, string>>' is not assignable to type 'ActionCreatorWithPayload<{}, string>'.
Type 'ActionCreatorWithoutPayload<string>' is not assignable to type 'ActionCreatorWithPayload<{}, string>'.
Types of property 'match' are incompatible.
Type '(action: Action<unknown>) => action is { payload: undefined; type: string; }' is not assignable to type '(action: Action<unknown>) => action is { payload: {}; type: string; }'.
Type predicate 'action is { payload: undefined; type: string; }' is not assignable to 'action is { payload: {}; type: string; }'.
Type '{ payload: undefined; type: string; }' is not assignable to type '{ payload: {}; type: string; }'.
Types of property 'payload' are incompatible.
Type 'undefined' is not assignable to type '{}'.
I don’t have a clean solution for this one yet and have raised the question on Stack Overflow. For now I’m using another feature of TypeScript 2.6 - // @ts-ignore
which can be used to ignore type checking for the immediately following line. This is reminiscent of flow’s // $FlowFixMe
or eslint’s // eslint-disable-next-line
.
I think it would be nice if there was more control over ignoring certain files or parts-of-files outside of switching off --strict
or --strict*
for the entire project and a GitHub issue exists to discuss this.
No type annotations on default export
You can’t add a a type annotation to a default export. Instead you’ll need to annotate a separate implementation with your type annotation and then export default
it.
type MyType = { ... };
export default: MyType { ... }; // ERROR: 'MyType' only refers to a type, but is being used as a value here.
// work-around
type MyType = { ... };
const MyImplementation { ... }: MyType;
export default MyImplementation; // ok
There’s an open GitHub issue here.
When are properties optional and when are they not?
Prior to TypeScript 2.0, undefined
and null
(difference explained here) could be assigned to any type. A problem with this is that the following assignment of b
and c
is valid;
// without --strictNullCheck and --strict
interface Foo {
x: number;
}
let a: Foo = {} // ERROR: Property 'x' is missing in type '{}' but required in type 'Mandatory'.
let b: Foo = { x: undefined } // ok
let c: Foo = { x: null } // ok
But what if you want to enforce x
to actually be a number
on b
and c
? With TypeScript 2.0 came --strictNullChecks
which only allows assignment of undefined
and null
to types that declared to include them. With --strictNullChecks
or --strict
we now get errors as follows;
// with --strictNullCheck or --strict
let b: Foo = { x: undefined } // ERROR: Type 'undefined' is not assignable to type 'number'.
let c: Foo = { x: null } // ERROR: Type 'null' is not assignable to type 'number'.
If want to allow undefined
or null
on our property with --strictNullChecks
, we have declare so in the type;
// with --strictNullCheck or --strict
interface Foo {
x?: number | null; // type is 'number | null | undefined'
}
let b: Foo = { x: undefined } // ok
let c: Foo = { x: null } // ok
These flags are compiler options, and so apply to the entire project. What if we now import
a library that expects regular type checking mode and supplies types with many non-optional properties? We now must satisfy the type checker for every property when using those types;
// with --strictNullCheck or --strict
type Foo = { // via library import
a: number;
b: number;
}
type Bar = {
c: number
} & Foo
const elizabeth = (args: Bar) => {}
elizabeth({a: 1, c: 1 }); // ERROR: Property 'b' is missing
But now, say, we desire Parent
properties to be optional on our Child
type? Typescript’s 2.1 Mapped Types gives us Partial
;
// with --strictNullCheck or --strict
type Foo = { // via library import
a: number;
b: number;
}
type Bar = {
c: number
} & Partial<Foo>
const elizabeth = (args: Bar) => {}
elizabeth({a: 1, c: 1 }); // ok
Differences in Interface Extension and Type Intersection
The core issue here I think stems from the (changing) differences between type
and interface
, and where I see code that type
aliases an object structure, I think that probably the author would be better off using an interface
. Using extend
and creating a Type Intersection with &
at first might feel like they are the same thing, but they are not.
On naming collision, where types don’t match, when using interface extends
the compiler will throw an error. Remember that --strictNullChecks
will change optional properties to instead include the type undefined
so the error messages with that flag will be slightly different to those shown below without the flag;
interface Foo {
a: string;
}
// without --strictNullChecks
interface Bar extends Foo {
a?: string; // ERROR
}
// ERROR:
// Interface 'Bar' incorrectly extends interface 'Foo'.
// Property 'a' is optional in type 'Bar' but required in type 'Foo'.
// without --strictNullChecks
interface Bar extends Foo {
a?: string | number; // ERROR: Subsequent property declarations must have the same type. Property 'a' must be of type 'string | undefined', but here has type 'string | number | undefined'.
}
// ERROR:
// Interface 'Bar' incorrectly extends interface 'Foo'.
// Types of property 'a' are incompatible.
// Type 'string | number' is not assignable to type 'string'.
// Type 'number' is not assignable to type 'string'.
So the above means we catch naming collisions at compile time - cool! We can create Type Intersections with type aliases and &
but extending in this way is not similar to extending an interface
;
type Foo = {
a: string | number;
};
type Bar = {
a?: string; // ok
} & Foo;
let b: Bar = { a: 'test' }; // ok
let c: Bar = { a: undefined }; // without --strictNullChecks ok, with --strictNullChecks ERROR: Type 'undefined' is not assignable to type 'string'.
let d: Bar = { a: 1 } // ERROR: Type 'number' is not assignable to type 'string'.
As shown above we can declare the Type Intersection without compiler error, but when we go to assignment things might not be as we are expecting. Creating a Type Intersection could also result in a property blocking any assignment at all;
type Foo = {
a: string | number;
};
type Baz = {
a: boolean; // ok
} & Foo;
let e: Baz = { a: 'test' } // ERROR: Type 'string' is not assignable to type 'never'.
The same effect can be seen where a property’s string literal types are merged via Type Intersection;
type OneTwo = {
a: "one" | "two";
}
type OneTwoThree = {
a: "two" | "three";
} & OneTwo;
let f: OneTwoThree = { a: "one" }; // ERROR: Type '"one"' is not assignable to type '"two"'.
let g: OneTwoThree = { a: "two" }; // ok
let h: OneTwoThree = { a: "three" }; // ERROR: Type '"three"' is not assignable to type '"two"'.
There’s huge discussion on why Type Intersections work this way in comments on its merged PR and why interface extends
and Type Intersections are not functionally consistent here.
Conclusion
All of the issues above have ways to deliver the expected end-result to production, but, as described sometimes the type checker requires a bit of deep diving to determine why things aren’t working as expected. While I’m head-banging over TypeScript I’ll start pondering type checking with Flow and JSDocs (I’ve worked with projects that use them also) but my gut feel for now is that I’ll be sticking with TypeScript for some time yet.