TypeScript 3 fundamentals
These are my notes taken on the online workshop by FrontendMasters.
Workshop instructor Mike North, who runs the developer training program and tech lead for TypeScript adoption at LinkedIn.
TypeScript intro
An open-sourced, typed, superset of JavaScript. It compiles (and runs as) standard JavaScript. This means that the type checking does not happen when the code runs in the browser, only at code authoring and compilation time.
There are three parts to the "ecosystem", the Language, the Language Server (giving information to a code editor) and the Compiler.
Note, that while Babel 7 can also compile TypeScript, it does not do type checking.
Why add type checking?
The idea is to that you make the constraints and assumptions about your application visible in your code. This helps reduce cognitive load, by serving as an extra documentation on the program (visible in a compatible code editor). It also helps catch code errors at compile time instead of at runtime.
TypeScript compilation
npm install -g typescript
Then to you can start compiling your *.ts
files by:
tsc src/index.js --target ES2017 --module commonjs --watch
The extra flags are of course optional, and self-explanatory.
Config file
A more complete tsconfig.json
file would be:
{
"compilerOptions": {
"module": "commonjs",
"target": "es2017",
"outDir": "lib",
"declaration": true,
"sourceMap": true
},
"include": ["examples/*/src", "challenges/*/src", "notes"]
}
The declaration option
It creates an extra index.d.ts
file that only contains the type declaration. It will be picked by the code editor (language server).
A more complete tsconfig setup
{
"compilerOptions": {
"jsx": "react",
"strict": true,
"sourceMap": true,
"noImplicitAny": true,
"strictNullChecks": true,
"allowJs": true,
"types": [],
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"moduleResolution": "node",
"target": "es2015"
}
}
TypeScript understandns JSX, so you can name your files .tsx
and it will know how to compile them.
The target option
You can also set it to esnext
and let Babel to the transpiling to your target environment.
Most of the other compiler options are related to strictness settings. These will be explained later in the article. Depending on how you start your code (convert from standard JavaScript or start from zero), you would enable these strictness settings progressively through several steps (and code commits).
Setting types
Letting the compiler infer the types
The easiest way is when the TypeScript engine can figure out by itself the type:
let foo = "bar";
The compiler will assign the type string
to foo
and will not let you assign a number
type to it.
const foo = "bar"
Again, the compiler can infer the type you are using, which is a JavaScript primitive type (e.g. string
or number
) and that it's a const
declaration and will warn you when you try to reassign a different value to it.
If foo
was an object:
const foo = {
bar: "baz"
}
The TypeScript compiler will understand that it's a const
declaration will try to guard you from changing the shape of the object (not allowed with const
declaration), but will let you reassign the value to foo.bar
(allowed with const
declaration).
Difference between variable declaration and initialisation
In the following example, we first declare a variable, and only initialise it with a new value later:
let foo;
foo = 42;
foo = "bar"; // the compiler doesn't show an error
In this case, the compiler will set the type of foo
to any
. It's a top type, meaning very wide ie. can hold many different types.
Type annotation
In the above case, where you declare a variable but don't initialise it, you can declare it's intended type:
let foo: number;
foo = 41 // no error
foo = "bar" // error
Mike recommends to add type annotations for variable declarations, as a means of code documentation and when you want to be explicit about the "contract".
Annotating arrays
See the following example. Without a type annotation, TypeScript will infer it as a never
type. It's so called bottom type, meaning no other type can go into it.
let foo = []; // we just initialised an array of nevers
foo.push(42) // compiler error, a number type cannot go into a never type
You can either set the type of the members of the array:
let foo: number[] = [];
foo.push(42); // works
foo.push("bar") // compiler error
To corretly declare an array that can hold any item (see above example with never
):
let foo: any[] = [];
Tuples
Tuples are fixed length arrays. You can define a type for each slot. If you do, the array will automatically a tuple, meaning it can not hold more than the predefined number of items.
let foo: [number, string, any, string, number] = [];
Note, that the compiler cannot check the Array.push()
calls (foo.push("bar")
). In practice, you would never build up an array incrementally so this is not really an issue.
Annotating objects
To decrlate the "shape" of an object:
let foo: { bar: number; baz: string };
foo = {
bar: 42,
baz: "xyz"
}
Now, all properties (bar
and baz
) become mandatory and the compiler will throw an error if you try to declare foo
without any of them.
To make them optional:
let foo: { bar: number; baz?: string }
Interface
You can define reusable type annotations that can be applied to JavaScript "object types" (Object, Function, Array).
interface HasPhoneNumber {
name: string;
phone: number;
}
interface HasEmail {
name: string;
email: string;
}
// then use one
let email: HasEmail {
name: "foo name",
email: "foo email"
}
Combining interfaces with Intersection or Union
Interfaces in TypeScript have many uses and also have some advanced behaviours (e.g. hoisting, see below).
Intersection types
let contactInfo: HasPhoneNumber | HasEmail;
Now contactInfo
must have a .name
property and may have an .email
or .phone
property:
contactinfo = {
name: "foo",
phone: 42
} // no error
contactinfo = {
name: "bar",
email: "baz"
}
Important to note, that with the Intersection contract we can only access the .name
property, but not .email
or .phone
(e.g. when you start typing in your editor contactinfo.|
, only name
is shown.
Union type
let fullContactInfo: HasPhoneNumber & HasEmail;
Now, when you define a variable based on the fullContactInfo
type, it must have all the properties of the two interfaces. Also when you use a variable based on this type, you have access to all their properties: contactinfo.|
will list name
, phone
and email
.
Nominal vs Structural type Systems
Let's consider this code snippet:
function validateInput(input: HTMLInputElement) {...}
validateInput(x)
There are two ways the compiler can check for type equivalence for the paramater x
. In a Nominal type systems, it would check based on instances or classes. Is x
an instance of HTMLInputElement
? This could only be possible in JavaScript if we wrote everything in a very OO style and use constructors for everything.
In Structural type systems, the engine (compiler) checks the shape of x
. Does it have the expected property names and their values are of the correct types?
The concept of wider and narrower types
A "wider" type is one that is less restrictive, a "narrower" is one that is more specific.
An example of type definitions going from wider to narrower:
any
-> anything can go hereany[]
-> narrower, must be an array ofany
ssting[]
-> array of strings[string, string, string]
-> 3 items of strings["foo", "bar", "baz"]
-> must be these thingsnever
-> the bottom type, nothing can go here
Annotating functions
function sendMail(to: HasEmail): { recipient: string; body: string } {
return {
recipient: `${to.name} <${to.email}>`,
body: "foo..."
}
}
Explanation:
(to: HasEmail)
: means the argument to
has to be the type of the interface HasEmail
(name
and email
props)
..): { recipient: string; body: string } { ... }
: defines the return type of the function
Fat arrow syntax
const sendMail(to: HasEmail): { recipient: string; body: string } => {
return ...
}
Mike recommends not overconstraining your argument types. It can be useful in different places and so should be able to accept different inputs.
On inferring return types
TypeScript is pretty good at inferring the return types of functions. If there are lot of branching inside the function and may return undefined properties, via for example an "early return" (e.g. if(x < 2) return;
) then in this case you may want to start specifying the return type.
The void return type
function foo(bar: number[]): void {
bar.forEach(...)
}
(My note) it's for functions that do side-effects and don't return a meaningful value.
Rest params
function sum(...vals: number[]) {
return vals.reduce(...)
}
Rest params work, but they must be an array type.
Function signature overloading
Mike presented a more complex use case, where a function receives two arguments. The two arguments are related, the value of the first argument has an effect on the acceptable type of the second argument.
function contactPeople(
method: "email" | "phone",
...people: (HasEmail | HasPhoneNumber)[]
): void {
// the implementation...
...
}
This definition would let you use it like this:
contactPeople('email', {name: "bar", phone: 42})
Where the two arguments don't match up.
Instead you would need to use signature overloading. You can add extra function type annotations that constrain further the base case:
function contactPeople(method: "email", ...people: HasEmail[]): void;
function contactPeople(method: "phone", ...people: HasPhoneNumber[]): void;
// then do the base case
function contactPeople(
method: "email" | "phone",
// or just
method: string,
...people: (HasEmail | HasPhoneNumber)[]
): void {
// the implementation...
...
}
Make sure to have the "base case" as well, not only the overloading. This is for the inside of the function (the // implementation
) part to have the correct type checking.
Lexical scope
You can set up TypeScript to check for the lexical scope (the type of the this
variable) based on the execution context.
function sendMessage(
this: HasEmail & HasPhoneNumber,
preferredMethod: "phone" | "email") {
// implementation that uses `this` (e.g. ... sendEmail(this))
...
}
TypeScript will now try to check for the this
variable:
sendMessage.call(null, "foo") // will show an error
sendMessage.call({name:"foo", phone:123, email: "bar"}) // will pass
You need at least TypeScript version 3.5 or 3.6 for the strict type checking for call
and apply
.
Type Aliases
You can name (assign to variables) your type declarations (similar to interfaces). You can assign to a type alias anything that you would do in a type declaration. In this sense, they are more flexible than interfaces.
type StringOrNumber = string | number;
// or
type HasName = { name: string };
Compared to interfaces
Interfaces can only be assigned to derivatives of the JavaScript Object type, but type aliases also work with primitive types.
Interfaces are similar to functions in that they can hoisted. Type aliases are read line by line by the compiler, and so hoisting will not work with type aliases, and so self-referential types won't work either:
type NumVal = 1 | 2 | 3 | NumArr;
type NumArr = NumVal[]; // this will NOT work
Interfaces
Interfaces can extend other interfaces (as you can extend JavaScript Objects):
interface InternationalPhone extends HasPhoneNumber {
countryCode: string;
};
Call signatures
Type aliases and Interfaces can be used to describe call signatures:
interface ContactMessenger {
(contact: HasEmail | HasPhoneNumber, message: string): void;
}
or with a type alias:
type ContactMessenger = (
contact: HasEmail | HasPhoneNumber,
message: string
) => void
Once you created a call signature, you can reuse it in your function declarations and don't need to declare the types there (for _contact
and _message
):
function emailer: ContactMesssenger (_contact, _message) {
...
}
This will also work for callbacks.
Construct signatures
For functions that you intend to be called with the new
keyword.
interface ContactConstructor {
new (...args: any[]): HasEmail | HasPhoneNumber;
}
This describes that your function called with any arguments, should return an object that has the "shape" of HasEmail
or HasPhoneNumber
.
Dictionaries
Dictionary is a key-value data structure. Usually the key is the lookup mechanism, e.g. an id or an index.
interface PhoneNumberDict {
[numberName: string]: undefined | {
areaCode: number;
num: number;
}
}
We need the undefined
"case" for when we attempt to look up a key that doesn't exist.
Then to use it:
const phoneDict: PhoneNumberDict = {
office: { areaCode: 123, num: 456 },
home: { areaCode: 789, num: 321 }
}
Combining dictionary interfaces
interfaces are "open", meaning any declarations of the same name are merged. You can extend an existing dictionary interface to make it more specific. You can declare the dictionary as above, or it can be imported from a library, then re-declare it with the same name to add more specific members to it.
interface PhoneNumberDict {
home: {
areaCode: number;
num: number;
};
office: {
areaCode: number;
num: number;
};
}
Now, the home
and office
members are mandatory, but you can add other members freely.
Again, you can take advantage of the "hoisting" of interfaces, and this extra declaration can come before or after the "base" declaration.
More on interfaces vs types
Types are sorted and evaluated eagerly, while interfaces lazily – only when we start to access them the compiler starts to work out the allowable types for it.
Testing TypeScript
Mike recommends the dtslint utility for testing your type declarations.
An example test file would have these lines. The comments are the assertions.
isJSONValue([]);
isJSONValue(4);
isJSONValue("abc");
isJSONValue(false);
isJSONValue({ hello: ["world"] });
isJSONValue(() => 3); // $ExpectError
isJSONArray([]);
isJSONArray(["abc", 4]);
isJSONArray(4); // $ExpectError
isJSONArray("abc"); // $ExpectError
isJSONArray(false); // $ExpectError
isJSONArray({ hello: ["world"] }); // $ExpectError
isJSONArray(() => 3); // $ExpectError
isJSONObject([]); // $ExpectError
isJSONObject(["abc", 4]); // $ExpectError
isJSONObject(4); // $ExpectError
isJSONObject("abc"); // $ExpectError
isJSONObject(false); // $ExpectError
isJSONObject({ hello: ["world"] });
isJSONObject({ 3: ["hello, world"] });
isJSONObject(() => 3); // $ExpectError
Classes in TypeScript
There are two extensions to the JavaScript class syntax, the implements
and access modifier keywords.
TypeScript implements
implements
means a class aligning with a particular interface:
class Contact implements HasEmail {
email: string; // need to declare here the fields that WILL exist
name: string;
constructor(name: string, email: string) { // then pass it to the constructor
this.email = email;
this.name = name;
}
}
Simplifying the syntax and access modifier keywords
The above syntax can be further simplified:
class Contact implements HasEmail {
constructor(
public name: string,
public email: strong = "no email"
) {
// nothing needed
}
}
The access modifier keywords are:
public
- everyoneprotected
- the class and it's subclassesprivate
- only the instancereadonly
Note, that these are only "active" inside TypeScript but not once the code has been compiled and running in a JavaScript environment.
So if we make email
protected:
class Contact implements HasEmail {
constructor(
public name: string,
protected email: strong = "no email"
) {
// nothing needed
}
}
const x = new Contact('foo', 'bar');
// x.| shows only x.name but not x.email
Make email
protected will also show a TypeScript warning, stating that the HasEmail
interface has not been correctly impelemented.
You can make other fields protected, that are not part of the interface:
class Contact implements HasEmail {
protected age: number = 0; // protected is protected has a default value
constructor(
public name: string,
public email: strong = "no email"
) {
// nothing needed
}
}
Declared fields must be initialised if no default value is provided:
class Contact implements HasEmail {
private password: string | undefined;
constructor(
public name: string,
public email: strong = "no email"
) {
this.password = "foo"
}
}
// or, you can use the difinite assignment operator (`password!: string`)
// to tell TypeScript that the field will be initialised
// conditionally or asynchronously but it WILL be initialised eventually
// Mike says this is useful to initialise fields in lifecycle hooks
class Contact implements HasEmail {
private password!: string;
constructor(
public name: string,
public email: strong = "no email"
) {
}
async init() {
this.password = "foo"
}
}
Another more extended solution to initialising a property is via the ES5 getter method:
class Contact implements HasEmail {
private password: string | undefined;
constructor(
public name: string,
public email: strong = "no email"
) {
}
get password(): string {
if(!this.passwordVal) {
this.passwordVal = "foo";
}
return this.passwordVal;
}
async init() {
this.password;
}
}
Abstract classes
Abstract classes are "base" classes, that cannot be instantiated directly. They can declare abstract methods, that must be implemented by any subclass that extends from it.
Mike calls them half classes, half interfaces, because like interfaces, abstract classes don't have their implementations either.
abstract class AbstractContact implements HasEmail, HasPhoneNumber {
public abstract phone: number; // this must be impemented by subclasses (satisy HasPhoneNumber)
constructor(
public name: string,
public name: string // this is to satisfy HasEmail
) {}
abstract sendEmail(): void; // this has to be implemented by the subclass
}
Converting to JavaScript to TypeScript
The base rules are
- don't change/add functionality while converting
- make sure you have good enough test coverage
- make sure to add tests for you types
- try to be too narrow or too "perfect" at first
Step 1
Just convert you .js
to .ts
files and enable allow for implicit any
types. Then go through the TypeScript errors and try to fix them.
Step 2
Ban any implicit any
s: "noImplicitAny": true
. Start adding the appropriate types. Import types from DefinetlyTyped. If not available add explicit any
s.
Step 3
Start increasing the strict settings:
"strictNullChecks": true,
"strict": true,
"strictFunctionTypes": true, // validates the argument types and return types of callback functions
"strictBindCallApply": true
Generics
Generics allow us to parameterize types in the same way that functions paramterize values
The type that pass to our interface becomes a variable and we can reuse it elsewhere in our code, for example to define the return type, or as a type definition for a callback function
interface WrappedValue<Foo> {
value: Foo
}
let val: WrappedValue<string[]> = { value: [] };
// val.value| -> the editor will show that it's a `string[]`
// another example:
interface FilterFunction<T> {
(val: T): boolean; // function that takes a parameter type T, and returns a boolean
}
// using the interface
function stringFilter: FilterFunction<string>(val) {
return typeof val === 'string'
}
stringFilter(0) // this throws an error
stringFilter('abc') // this is OK
A more advanced example, that shows how to pass a type parameter that will define the type that a Promise
will resolve to:
function resolveOrTimeout<T>(apromise: Promise<T>, timeout: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
// start the timeout, reject when it triggers
const task = setTimeout(() => reject("time up!"), timeout);
apromise.then(val => {
// cancel the timeout
clearTimeout(task);
// resolve with the value
resolve(val);
});
});
}
resolveOrTimeout(fetch(""), 3000); // fetch resolves to a Response object
// now, that we call resolveOrTimour with fetch, TypeScript will
// infer that the resolveOrTimeout takes/returns a Response
Constraining type parameters
You can refine the type parameter via the extends
keyword. This lets you set a "base shape" for the type parameter, that is flexible enough to contain other properties as well.
function arrayToDict<T extends { id: string }>(list: T[]): { [k: string]: T } {
const out: { [k: string]: T } = {};
list.forEach(val => {
out[val.id] = val;
});
return out;
}
// the usage:
const myDict = arrayToDict([
{ id: "a", value: "first", lisa: "Huang" },
{ id: "b", value: "second" }
]);
By defining that <T extends {id: string}>
, we guarantee that in the list.forEach
part, out[val.id]
will exist in our dictionary.
On the other hand, if we would type the arguments for arrayToDict
as:
function arrayToDict(list: {id: string}[]) ....
We would not be able to pass the value
and list
properties (see myDict
above).
Note, that you have to evaluate each time if you really need to use generics. It's useful when for example the type of an input argument is meaningful inside the function or appears somewhere in the output of the function. See the related FrontendMasters video
The following example shows such an example, where a generic would not make sense:
interface Shape {
draw();
}
interface Circle extends Shape {
radius: number;
}
function drawShapes1<S extends Shape>(shapes: S[]) {
shapes.forEach(s => s.draw());
}
function drawShapes2(shapes: Shape[]) {
// this is simpler. Above type param is not necessary
shapes.forEach(s => s.draw());
}
The generic would make sense, if you were returning something, so instead of forEach
, you do .map
:
function drawShapes1<S extends Shape>(shapes: S[]) : S[] { // notice we added the return type
shapes.forEach(s => s.draw());
return shapes.map(s => {
// ... do stuff
s.draw();
return s;
})
}
Use example for mapping a dictionary
Here's an example written for the FrontendMasters course that implements a mapping function to be used on dictionary types. It's a great example of using type paramaters:
type Dict<T> = {
[foo: string]: T | undefined
}
function mapDict<T, S>(
dict: Dict<T>,
fn: (arg: T, idx: number) => S
): Dict<S> {
const out: Dict<S> = {};
Object.keys(dict).forEach((dKey, idx) => {
const thisItem = dict[dKey];
if (typeof thisItem !== 'undefined') {
out[dKey] = fn(thisItem, idx);
}
});
return out;
}
Top and bottom types
Top types
Top types are the "widest" types, e.g. any
The unknown type
It's similar to any
in that it can take any JavaScript type. The difference is in accessing props. In the case of unkown
type, TypeScript doesn't let you access the properties.
let myUnknown: unknown = "foo"
myUnknown.bar // not allowed in TypeScript
Use cases
When you are building a library and don't want the users of your object to depend on the shape. For example setTimeout
returns a number (the id of the timeout). Here it doesn't really matter if it's a number
type or something else, you just use it and reference it.
You need to narrow down the type before you can use it, using type guards.
When to use any
When you need maximum flexibility. For example when we don't care about the return value of a Promose:
async function logWhenResolved(p: Promise<any>) {
const val = await p;
console.log("Resolved to: ", val);
}
Type guards
(Note, this section my need some revision, I may not have understood correctly the introduction)
Built-in type guards
(again, I'm not 100% sure of this)
You can check for a type of a variable (via typeof
or instanceof
) and if it's true, the compiler will update the type of that variable inside that scope.
let myUnknown: unknown = "foo";
if (typeof myUnknown === "string") {
// in here, myUnknown is of type string
myUnknown.split(", "); // ✅ OK
}
if (myUnknown instanceof Promise) {
// in here, myUnknown is of type Promise<any>
myUnknown.then(x => console.log(x));
}
User defined type guards
To create user defined type guards, you have to declare the return value of a function as function foo(bar: any): bar is HasEmail { ... }
.
If the function returns true
it's an indication for the TypeScript compiler that bar
is of type HasEmail
function isHasEmail(x: any): x is HasEmail {
return typeof x.name === "string" && typeof x.email === "string";
}
if (isHasEmail(myUnknown)) {
// In here, myUnknown is of type HasEmail
console.log(myUnknown.name, myUnknown.email);
}
// my most common guard
function isDefined<T>(arg: T | undefined): arg is T {
return typeof arg !== "undefined";
}
// use example, filter for defined items
const list = ['a', 'b', 'c', undefined, 'e'];
const filtered = list.filter(isDefined); // type here is filtered: string[]
Branded types and casting
Because unknown
is a top type, this is possible:
let aa: unknown = 42;
let bb: unknown = "foo";
bb = aa;
Branding values with types can help you to "package" them. It's a pattern, where you cast (myval as MyType
) a value to specific type. Usually these "branding" types are defined in one place in your codebase. The idea is to communicate that you intentionally wrapped an unknown value, like a mail envelope, where you don't see the contents but has a stamp on it – and you know where it came from.
To access the original value you need an unbranding function that is specific to that branded type. An unbranding function will not work from a different type.
interface BrandedA {
__this_is_branded_with_a: "a";
}
function brandA(value: string): BrandedA {
return (value as unknown) as BrandedA;
}
function unbrandA(value: BrandedA): string {
return (value as unknown) as string;
}
interface BrandedB {
__this_is_branded_with_b: "b";
}
function brandB(value: { abc: string }): BrandedB {
return (value as unknown) as BrandedB;
}
function unbrandB(value: BrandedB): { abc: string } {
return (value as unknown) as { abc: string };
}
let secretA = brandA("This is a secret value");
let secretB = brandB({ abc: "This is a different secret value" });
secretA = secretB; // No chance of getting these mixed up, this will show an error
unbrandB(secretA); // not possible, the "brands" don't match up
unbrandA(secretB); // not possible
// back to our original values
let revealedA = unbrandA(secretA);
let revealedB = unbrandB(secretB);
Again, using this "branded" pattern is a way to communicate with other developers on now they should access it (not access it). Mike often uses it for id
generation.
Bottom types
The bottom type is never
. It cannot hold any value. It can still be useful, as a way to catch cases, branches in your code where you didn't expect your program to go through. So it's a great way to catch bugs at compile time.
"exhaustive switch": when you expect your code to test all possible cases. If a variable doesn't match any of the cases, then you can set up TypeScript to warn about it
class UnreachableError extends Error {
constructor(val: never, message: string) {
super(`TypeScript thought we could never end up here\n${message}`);
}
}
let y = 4 as string | number;
if (typeof y === "string") {
// y is a string here
y.split(", ");
} else if (typeof y === "number") {
// y is a number here
y.toFixed(2);
} else {
// TypeScript will warn you if your code would go through here:
throw new UnreachableError(y, "y should be a string or number");
}
The above code works with no error thrown. If you would widen the type of var y
then you would arrive to the last case and would get an error:
let y = 4 as string | number | boolean;
// UncreachableError would now be called
Now, you would get an error from TypeScript that UnreachableError
was called with a type boolean
, and it expected never
type (constructor(val: never, message: string) {...}
).
Mapped types
This is a more flexible solution to overloading interfaces (see above). Instead of defining pairs of communication types ("email", "phone", "fax", see below) with acceptable data pairs, we create a map:
interface CommunicationMethods {
email: HasEmail;
phone: HasPhoneNumber;
fax: { fax: number };
}
// now we can just use the key and it's value to define the acceptable pairs:
function contact<K extends keyof CommunicationMethods>(
method: K,
contact: CommunicationMethods[K] // turning key into value -- a *mapped type*
) {
//...
}
// all these work now
contact("email", { name: "foo", email: "mike@example.com" });
contact("phone", { name: "foo", phone: 3213332222 });
contact("fax", { fax: 1231 });
// we can get all values by mapping through all keys
type AllCommKeys = keyof CommunicationMethods;
type AllCommValues = CommunicationMethods[keyof CommunicationMethods];
Again, you could set up one place in your code, where these relationships are maintained and import them into your modules.
Type queries
You can get the type of a value using typeof
const alreadyResolvedNum = Promise.resolve(4);
type ResolveType = typeof Promise.resolve;
const x: ResolveType = Promise.resolve;
x(42).then(y => y.toPrecision(2));
Ternary types
We can use conditionals and TypeScript infer to further refine the type of a variable
type EventualType<T> = T extends Promise<infer S> // if T extends Promise<any>
? S // extract the type the promise resolves to
: T; // otherwise just let T pass through
let a: EventualType<Promise<number>>;
let b: EventualType<number[]>;
"Grab the type of the Promise, or let it fall through if it's not a Promise"
Buit-In Utility Types
Partial utility type
Lets you create a object that only has a subset of the properties of the original interface:
type MayHaveEmail = Partial<HasEmail>;
const me: MayHaveEmail = {}; // everything is optional
// MayHaveEmail can now contain any of the properties of HasEmail
Pick
It allows you to select one or more properties from an object type. Similar to many other utility libraries methods.
type PickA = Pick<{a:1, b:2}, "b">
const y: PickA;
// y.| -> it only has `.b` here
Extract
You can exclude based on type:
type OnlyStrings = Extract<"a" | "b" | 1 | 2, number>;
Exclude lets us obtain a subset of types that are NOT assignable to something
type NotStrings = Exclude<"a" | "b" | 1 | 2, string>;
Record helps us create a type with specified property keys and the same value type
type ABCPromises = Record<"a" | "b" | "c", Promise<any>>;
Declaration merging
In TypeScript, identifiers (var, class, function, interface...) can be associated with three things: value, type or namespace.
function foo() {}
interface bar {}
namespace baz {
export const biz = "hello";
}
Values can be used as an RHS assignment (right hand side). Functions and variables are purely values, and so their types can only be extracted using type queries.
// how to test for a value
const x = foo; // foo is in the value position (RHS).
// getting the type of a value
const yy: typeof xx = 4;
A type can only be on the LHS of an assignment. Interfaces are purely types, and so they cannot be on the RHS.
// how to test for a type
const y: bar = {}; // bar is in the type position (LHS).
// interface cannot be on the RHS:
const z = bar; // ERROR
// how to test for a namespace (hover over baz symbol)
baz;
export { foo, bar, baz }; // all are importable/exportable
Classes
Classes can be both, types and values. It can capture the class itself or it can be used as a type.
class Contact {
name: string;
}
// passes both the value and type tests
const contactClass = Contact; // value relates to the factory for creating instances
const contactInstance: Contact = new Contact(); // interface relates to instances
If the class is used as a type, it can be used to describe the instances themselves.
Declaration merging
Declaration with the same name can be merged. In the following example Album
can be all three, a value (class), an interface, and a namespace. The interface merges on the class, so you can stack an interface on the top of a class for example.
class Album {
label: Album.AlbumLabel = new Album.AlbumLabel();
}
namespace Album {
export class AlbumLabel {}
}
interface Album {
artist: string;
}
let al: Album; // type test
let alValue = Album; // value test
// when you export Album, and hover over the value
// it's all three (class, interface, namespace)
export { Album }; // hover over the "Album" -- all three slots filled
To explain what "stacking" means, let's create a new "Album":
let al2 = new Album();
// al2.| -> you see both .label (from the class) and .artist (from the interface)
So this is really useful if someone forgot to add all the type information on their library. You can stack the interface on it, to add the extra information. And it applies globally in your app.
Aside on how the types are stored in your app
The modifications you make to your types (e.g. declaration merging) are app wide, even if you don't export them explicitly, or if you are importing a module from somewhere.
Namespaces
A namespace is neither a class or an interface. They can be merged with classes or functions.
class AddressBook {
contacts!: Contact[];
}
namespace AddressBook {
// can create "inner classes"
export class ABContact extends Contact {} // inner class
}
const ab = new AddressBook();
ab.contacts.push(new AddressBook.ABContact()); // refer to the innerclasss
These "inner class" are possible because namespace can merge with classes. These inner classes are a bit like the static
properties (accessible with instantiating the class).
// 💡 or functions
function format(amt: number) {
return `${format.currency}${amt.toFixed(2)}`;
}
namespace format {
export const currency: string = "$ ";
}
format(2.314); // $ 2.31
format.currency; // $
Notice, that there's no collision above, with the namespace
we can either invoke format(123)
or access a property format.currency
.
Aside on the static property
Not strictly related, but I'm happy that someone cleared this up. Classes in JavaScript can have a static
property, that are "directly accessible".
This just means that these properties are accessible without running the class with the new
keyword:
class Contact {
name: string;
static hello = "world";
}
// now you can access the static property:
Contact.hello // (porperty) Contact.hello: string