Skip to content

Instantly share code, notes, and snippets.

@rebolyte
Last active May 13, 2020 23:17
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save rebolyte/cccb6a013dec15afce39a37db39d7c0b to your computer and use it in GitHub Desktop.
Save rebolyte/cccb6a013dec15afce39a37db39d7c0b to your computer and use it in GitHub Desktop.
TypeScript basic intro

TypeScript intro

James Irwin
7-Jun-2018

TypeScript is a typed superset of JavaScript. All JavaScript is valid TypeScript, and TS transpiles to JS to run in browsers (much like Babel transpiles ES6+ to ES5). In transpilation, all types are stripped out; you get type-checking at compile-time only (but run-time type-checking is needed much less because of it). It is developed by Microsoft, powers apps you know like VS Code, and is in use at companies like Lyft, Reddit, and Slack.

Definition files

TypeScript lets you use existing JavaScript libraries in a strongly-typed manner by providing type definition files, which are separate files that annotate a given library's API. When you go to import lodash, you will likely also want to import @typings/lodash, which will install a lodash/index.d.ts file in your dependencies. Some libraries include definition files themselves on NPM, but others are available through the @typings organization, which is a mirror for DefinitelyTyped, a massive repository of user-contributed definition files.

Once you have lodash and its definition files, TS will find it and be able to give you type information for lodash's methods. Built-in definition files are actually how TS gives you typing information for native JS things like, among other things, Object.assign(), various DOM APIs like document.createElement(), or Promise. For example, if you have TS configured to transpile to ES5, it will tell you that it doesn't have a value for Promise, since that is an ES6 feature, and it is using its internal ES5 library definition file.

TypeScript Definition Files on Egghead

Basic types

let pi: number = 3.1415926;

// pi = 'abc'; // -> error, `pi` is of type number

let arr: number[] = [1, 2, 3, 4];

let arr2: number[] = arr.map((num: number) => num * 2);

let str: string = 'testing';

let bool: boolean = true;

let arrTest: any[] = [0, 1];

let el = Element; // using built-in DOM type definitions

let obj: object = { a: 1 };

let nope: null = null;

let nope2: undefined = null; // -> error with strictNullChecks, undefined !== null

let v: void; // absence of any type. usually used as function return type, and can only assign `null` or `undefined` to vars marked with it

let anything: any = 'abba'; // escape hatch, basically saying "don't type-check this"

Strict mode

TS offers a --strict compiler option, which is a shortcut for a set of compiler options that make TS, well, more strict. The most important of these is noImplicitAny, which requires all declarations whose types cannot be inferred to be explicitly annotated with any:

function identity(arg) {
	return arg;
}
// error: `Parameter 'arg' implicitly has an 'any' type.`

any is kind of TypeScript's notion of how JavaScript handles all variables; each one could be anything. Since we are adopting TS to enforce more consistency, it's much more useful for it to make us type everything, and explicitly say when we want type to be open-ended.

With explicit annotation:

// explicitly-typed function parameters (gone over more below)
function identity(arg: any) {
	return arg;
}
// ok

The second-most important flag is strictNullChecks, which limits how null and undefined can be used. By default they are assignable to all types in TypeScript:

let foo: number = 123;
foo = null; // ok
foo = undefined; // ok

With the flag enabled, null and undefined are not automatically assignable to all other values, or each other:

let foo: number = 123;
foo = null; // error `Type 'null' is not assignable to type 'number'.`
let bar: null;
bar = undefined; // error `Type 'undefined' is not assignable to type 'null'.`

These, along with the other flags enabled with the --strict option, are highly recommended if you aren't integrating with legacy JS and incrementally adding TS.

Inferred typing

If you don't specify a type, TS infers the type of a variable based on its first assignment.

let inferredString = "this is a string";
let inferredNumber = 1;
inferredString = inferredNumber; // error

TS also infers types for complex objects (duck typing):

let complexType = { name: "myName", id: 1 };
complexType = { id: 2, name: "anotherName" }; // ok
complexType = { id: 2 }; // error
complexType = { name: "extraproperty", id: 2, extraProp: true }; // error

Casting

You can explicitly tell TS to treat something as a specific type:

let val: any = 'a string, perhaps';
let len: number = (<string>val).length; // or (val as string).length
console.log(len); // -> 17

This does not actually change the underlying data, and only tells the compiler you know what you're doing.

Prefer the as syntax, as it is required in .tsx files to avoid collisions with JSX syntax.

Enums

Enums define a set list of constants:

enum Color {Red, Green, Blue}
let c: Color = Color.Blue;

Enums generate something like the following JS:

var Color;
(function (Color) {
    Color[Color["Red"] = 0] = "Red";
    Color[Color["Green"] = 1] = "Green";
    Color[Color["Blue"] = 2] = "Blue";
})(Color || (Color = {}));
var c = Color.Blue;

This way, you can go both directions, looking up name or indices:

console.log(c); // -> 2
console.log(Color[2]); // -> 'Blue'

Const enums

Const enums exist largely for performance reasons, and do not output a closure like regular enums, but inline each value directly.

const enum Color {Red, Green, Blue}
let c = Color.Red;

The corresponding JS output:

var c = 0 /* Red */;

This means you can't use the internal string reference (e.g. Color[0]), so use a string property accessor (e.g. Color['Red']).

The preserveConstEnums compiler option tells TS to still output the closure-style enum in the transpiled JS, but references to it will still be inlined. (var c = 0 instead of var c = Color.Blue).

Functions

You can annotate the types of parameters, as well as the function's return type.

function add(a: number, b: number) {
	return a + b;
}

function doStuff(): void {
	console.log('i do something but give nothing back');
}

Note that TS can infer the functions return type (add() will return a number).

Denote optional parameters with ?, and place them after any required parameters:

function concatStrings(a: string, b: string, c?: string) {
	return a + b + c;
}

Example with ...rest and default arguments:

function serialize(sep: string = '&', ...strs: string[]) {
	return strs.join(sep);
}

serialize(',', 'a', 'b', 'c'); // -> 'a,b,c'
serialize(undefined, 'a', 'b', 'c'); // -> 'a&b&c'

Function signatures

Function signatures look like (params) => type:

function doThing(
	num: string,
	callback: (numStr: string) => number
) {
	console.log('calling callback with:', num);
	callback(num);
}

doThing('test', numStr => parseInt(numStr, 10)); // ok
doThing('test', numStr => numStr + 10); // error, callback doesn't return string

Function overloads

You can define overloads by specifying signatures without a function body, and ensuring that the final signature uses the any type:

function add(a: string, b: string) : string;
function add(a: number, b:number) : number;
function add(a: any, b: any): any {
	return a + b;
}

Note that despite the any type signature, we are only exposing the overloads defined above it. We have to specify the final signature with any because that's the actual function body that will be transpiled, and in strict mode we can't implicitly leave the arguments as any.

Tuples

A tuple is a list of known length length, which can have values of different types:

let tup: [string, number];
tup = ['key', 10];
tup = [1, 1] // error on incorrect tuple initialization
tup = ['key', 1, 1]; // error, `Types of property 'length' are incompatible.`

Interfaces

Interfaces are compile-time only, and are completely removed from the generated JS. Examples:

interface IUser {
	id: number,
	username: string,
	fullName?: string // optional property
}

let u: IUser = { id: 1, username: 'ct01' };

function saveUser(user: IUser): void {
	console.log('i got a user');
}

saveUser({ id: 1, username: 'vh1000', fullName: 'van halen'}); // ok

Optional properties can check available types and prevent extra props being passed:

saveUser({ id: 2, username: 'vh1001', fullNam: 'testing' }); // error (note typo), `Object literal may only specify known properties`

Can be worked around if needed to with casting:

saveUser(({ id: 1, username: 'vh1000', firstAndLastName: 'van halen'}) as IUser); // ok

Here's an example of defining a function signature on an interface:

interface IThing {
	name: string;
	serialize(separator?: string): string; // function signature
}

let item: IThing = {
	name: 'bobby',
	serialize() {
		return this.name;
	}
};

Interfaces can be inherit from each other as well:

interface Thing {
	name: string;
}

interface TaggedThing extends Thing {
	tags: string[]
}

let a: TaggedThing = { name: '', tags: ['tag1'] };

Readonly

readonly is to properties as const is to variables. Once a readonly property has been intialized, it cannot be changed:

Interfaces:

interface Point {
	readonly x: number;
	readonly y: number;
}

Arrays:

let ro: ReadonlyArray<number> = [55, 66, 77];
ro.push(100); // -> err

See also ReadonlyMap and ReadonlySet.

Indexable types

TS can define an index signature that describes the types we can use to index into an object:

// using `Point` from above
interface PointArray {
	[index: number]: Point;
}
let pArr: PointArray = [{ x: 1, y: 1 }, { x: 2, y: 2 }]

interface NumberDict {
	[index: string]: number;
}
let nd: NumberDict = { a: 1, b: 'abc' }; // error, `b` is not number

Indexable types let us enforce both the type of the object's key and the return type of indexing into the object.

Type aliases

Type aliases can be specified with the type keyword, and can kind of act like interfaces, but should really be used to meaningfully label primitives, not represent complex objects.

type Name = string;

const getName = (n: Name) => n;

This is not terribly useful on its own, other than to potentially make something clearer. Aliases do not create new types, just allow you to reference existing ones by another name (the return type of getName is still just string).

Function signatures can also be aliased:

type CallbackWithString = (string) => void;

function usingCallbackWithString(callback: CallbackWithString) {
		callback('this is a string');
}

One more example with a tuple (potentially inadvisable; there are better ways to do this):

type Alive = boolean;
type Health = number;
type Player = [Alive, Health];

let p: Player = [true, 100];

Aliases are most useful with union and intersection types.

Union types

let unionType: string | number;
unionType = 1;
unionType = 'test';
unionType = []; // error

They can be especially useful with type aliases:

type StringOrNumber = string | number;

function addWithAlias(arg1: StringOrNumber, arg2: StringOrNumber) {
	return arg1.toString() + arg2.toString();
}

Intersection types

In this example, x has all properties of both Point and Thing:

type PointThing = Point & Thing;

let x: PointThing;

I think of these as "summation" types, since they are not the intersection of only properties that exist on both T and U.

Type guards

Problematic code:

function addWithUnion(
	arg1: string | number,
	arg2: string | number
) {
	return arg1 + arg2;
}

TS will throw Operator '+' cannot be applied to types 'string | number' with this code, since it can't determine what arg1 is, which must be known to determine whether + means concatenation or addition. The solution is adding type guards:

function addWithTypeGuard(
	arg1: string | number,
	arg2: string | number
): string | number {
	if (typeof arg1 === 'string') {
		// arg1 is treated as string within this block - check intellisense!
		console.log('first argument is a string; performing concatenation');
		return arg1 + arg2;
	}
	if (typeof arg1 === 'number' && typeof arg2 === 'number') {
		// arg1 and arg2 are treated as numbers within this block
		console.log('both arguments are numbers; performing addition');
		return arg1 + arg2;
	}
	// we will only get here if a number and then a string are passed, like (2, '3')
	console.log('default return');
	return arg1.toString() + arg2.toString();
}

This shows how in TS, we sometimes have to add JS-style runtime validation, which TS can understand and use.

Null and undefined

Just like in JS, they are not the same thing. If we want to make the caller be explicit and always pass some value, we can denote what's allowed using a type union:

function testUndef(test: null | number) {
	console.log('test parameter:', test);
}

If we have the strictNullChecks compiler option enabled, now just calling testUndef() like we might in JS will result in an error: Argument of type 'undefined' is not assignable to parameter of type 'number | null'. In this example, you'd have to call testUndef(null) explicitly.

Classes

Classes are an ES6 feature, but TS adds access modifiers and some handy shortcuts.

class Vehicle {
	constructor(theName: string) {
		this.name = theName;
	}
	private id: number;
	name: string; // `public` is default
	print(): void {
		console.log(this.name);
	}
}

new Vehicle('truck').id; // -> error, `name` is private

public members are accessible to anyone, protected members can be accessed by the derived classes, private are internal. Properties can also be marked as readonly.

Using a protected constructor means the class cannot be instantiated outside of its containing class, but can be extended.

"Parameter properties" create and initialize a member - prepend a constructor parameter with an access modifier or readonly:

class Truck extends Vehicle { // inheritance as usual
	constructor(private v8: boolean) { 
		super('Truck');
		// no this.prop = theProp nonsense
	}
}
let toyota = new Truck(true);

Classes can implement interfaces:

interface IVehicle {
	name: string;
	print(): void;
}

class Vehicle implements IVehicle {
	public name: string;
	print() {
		console.log(this.name);
	}
}

let v = new Vehicle();

TS would warn you if Vehicle omitted the name property or marked it as something other that public.

Constructor overloads follow the same rules as function overloads:

class Vehicle {
	id: number;
	name: string;
	constructor(idArg: number, nameArg: string);
	constructor(idArg: string, nameArg: string);
	constructor(idArg: any, nameArg: any) {
		this.id = (typeof idArg === 'number') ? idArg : parseInt(idArg, 10);
		this.name = nameArg;
	}
}

let v = new Vehicle(1, 'honda');

let v2 = new Vehicle('KE7QEL', 'chevy'); // initialized with string, so v2.id === NaN

Even though we specified the id property to be a number, we must make sure it's initialized as such in the constructor, as TS will not generate an error in this case and will not try to coerce the value to a number on its own. This is another instance of TypeScript's relation to JS, where runtime type checking is sometimes necessary.

Classes can be used as types! Class definitions create both a type representing instances of that class and a constructor function. Put another way, a class has two sides to its type: the static side and the instance side. So you can use classes like interfaces:

// continued from block above
interface TaggedVehicle extends Vehicle {
	tags: Array<'4x4' | 'hybrid' | 'electric'>
}

let t: TaggedVehicle = { id: 213, name: 'toyota', tags: ['4x4'] }

TS also has abstract classes.

Namespaces

Sometimes classes or interfaces have naming conflicts. To solve this, TS has namespaces:

namespace MyStuff {
	class Thing { }
	export class Thang { }
}

To make things visible outside a namespace, you export them. This would then be referenced as new MyStuff.Thang().

Namespaces can be defined across multiple files, and accessed as if they were all in one place.

Generics

Generics allow us to specify a type variable that can be provided by the caller.

Functions:

function list<T>(arg: T): T[] {
	return [arg];
}

// Here we explicitly specify the type
let res = list<string>('test'); // return type is string[]

// this uses type argument inference: TypeScript is setting T for us based on the type of the argument passed in
let res2 = list(123); // return type is number[] 

Classes:

interface IUser {
	id: string;
	name: string;
}

class Endpoint<T> {
	get(): Promise<T> {
		return fetch().then(resp => resp.json());
	}
}

let userEndpoint = new Endpoint<IUser>();
userEndpoint.get().then(user => user.name); // user is typed as IUser

Using generics and intersection types:

function extend<T, U>(arg1: T, arg2: U): T & U {
	return Object.assign({}, arg1, arg2);
}

let a = { one: 1 };
let b = { two: 2 };

let c = extend(a, b); 
// c is of type `T & U` put together: `{ one: number; } & { two: number; }`

Here again we're using type inference by not specifying the generics when calling extend.

Working with built-in definitions

Quick example of working with TypeScript's built-in definitions, here extending its definition of HTMLElement (a DOM node) with a newer method that they haven't included yet:

// `.prepend` does not exist in TS DOM types yet, so we add it here
interface HTMLElementExtended extends HTMLElement {
	prepend(...HTMLElement: any[]): undefined;
}

let node = document.createElement('h1');
node.innerText = 'TypeScript + React';
node.classList.add('mb-4');
// https://caniuse.com/#feat=dom-manip-convenience
(document.getElementById('container') as HTMLElementExtended).prepend(node);

Without casting the element as our new type, TS complains that Property 'prepend' does not exist on type 'HTMLElement'.

Resources

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment