OBS: This article is outdated. Most recent versions live in https://wkrueger.gitbook.io/typescript/
This aims a reader who already has some modern JS experience and is curious about TS. Special focus is given on presenting how the type system works.
What will we go through:
- What is typescript for? What typescript isn't. Why. Why not;
- Set it up as simply as possible;
- Type system overview;
- Caveats from someone used to JavaScript;
Index:
- 1. What does TypeScript do?
- 2. What TS is NOT for
- 3. The simplest build possible
- 4. Types are spooky (how types work)
-
5. Mutable code and types
- Productive use of loose types and
any
- Productive use of loose types and
- 6. Primitive types
- 7. Interfaces vs. Type Aliases
- 8. Class'es particularities
- 9. Structural typing and you
- 11. Control flow analysis
- 11. Other more advanced type syntaxes
- 12. Generics
- 13. Modules
- 14. 3rd party types
(PS: This ended up being a quite lengthy text, but splitting it didn't really seem a cool idea).
Asterisks (*) scattered around the text indicate parts where I admit I may be sacrificing canonical correctness in favor of prose terseness.
1. What does TypeScript do?
Type checking, works like a linter
TypeScript is used as sort of an advanced linter, as it points errors in your code based on the coherence of the data structures present in it. I emphasize the term linter here because type-check errors really don't block your code from being compiled. The errors are just there to provide you hints.
In order to collect those data structures, TS uses inference in your code. TS already knows a lot of type data from plain JS alone, but you can also complement those with extra type annotations.
JavaScript compilation
As type annotations are not understood by JS parsers, source .ts
files must be compiled to .js
in order to remove those. Typescript itself includes a compiler and nowadays this can also be done with Babel.
The TS language aims to keep aligned with JS and proposals that had reached stage 3 ("surely coming to JS"). TS aims NOT to include extraneous features that are not or won't be part of JS.
So, by writing TS, you are mostly writing a near future version of JS with types. As with Babel, you can then choose which target to compile (how old is the browser or node.js version you wish to support).
Language services
Language service support is a big focus and differential of TypeScript. A language service is a layer which aims to provide editor goodies like tooltips, navigations, completions, refactors, and suggestions, a dozen of small features which actually bring big improvements in developer experience. The opposite case would be a language in which you only get the compiler feedback when you save a file.
As the TS team works in tandem with the VSCode team to provide its JS language service, its editor experience is very refined.
2. What TS is NOT for
Writing OOP-styled code like C# or Java;
As TS is mostly "JS with types", you should just write TS as you would write JS, whatever code style you prefer. As classes are a JS feature, you could already write classy code in plain JS.
Making my code verbose, littered with type annotations;
Force me into writing in OOP style;
Since it is made to fit already existing JS patterns, TS's type system is quite flexible. The type system does not strongly dictate what patterns you should use. This, paired with the heavy use of inference allows for the usual TS code to have a small amount of type annotations.
Due to the nature of static typing, you will eventually need to adapt some dynamic patterns or lean to more functional patterns, but those will be tiny and beneficial changes. More info on that ahead.
Real cons of using TypeScript
Setting up TS in modern frontend projects (webpack-based) used to be a pain. This has changed drastically since the Babel integration came, along with support on popular templates like create-react-app. Community support in this area has now raised a lot, bringing goodies like better library typings.
3. The simplest build possible
Using the TypeScript compiler (tsc
) is the most simple way to get started. Probably simpler than any Babel-related setup you've ever used. tsc
can be added to your PATH by globally installing TypeScript (npm i -g typescript
).
tsc -w main.ts
... generates a main.js
file in the same folder with default compiler settings. -w
toggles the watch mode.
A simple project
For a project, it is recommended that you install TypeScript locally so that your project is tied to a specific TS version. In VSCode, tsc
can be invoked through F1 > Run Build Task. You should also include a link for it in the package.json scripts
.
tsc
looks for a tsconfig.json
file in the same folder. This also allows it to be called without arguments. The tsconfig
accepts an overwhelming set of compiler options -- since it mixes compiling and type checking options. Below I'll go through a set of recommended settings.
{
"compilerOptions": {
...
},
"include: ["src"]
}
-
include
filters which files to compile. This can be a folder or an entry point (every file referenced by that entry point will also be compiled);
I will usually split input and output files in different folders:
|__ built
| |__ index.js
|__ src
| |__ index.ts
|__ tsconfig.json
- By default
tsc
outputs to the same folder the source files are. Use"outDir": "built"
to fix that;
"sourceMap": true
- Sourcemaps allow you to debug directly in the source
.ts
files.
"target": "es2017",
"module": "esnext",
"esModuleInterop": true
Those 3 are output settings:
-
target
dictates how old is the runtime you want to support; -
module
allows for import/export syntax conversion; You'd usually use "esnext" (no conversion*) when using a bundler, or "commonjs" for node; -
esModuleInterop
is an es-modules "quirk" fix;
"strict": true,
"noImplicitAny": false,
Type-checking options:
-
strict
turns on all of the latest type-checking features (very important); -
noImplicitAny
disables one specially annoying feature with a good trade-off (personal opinion);
"lib": ["dom", "es2015", "es2017"],
-
lib
is entirely optional and allows tuning of which global-environment types are available; For instance, the default setting includes "dom", but you'd like to disable "dom" types in a node.js project.
Concluding it, we got:
{
"compilerOptions": {
"target": "es2017",
"module": "esnext",
"esModuleInterop": true,
"strict": true,
"noImplicitAny": false,
"lib": ["dom", "es2015", "es2017"],
"outDir": "dist",
"sourceMap": true
},
"include": ["src/index.ts"]
}
4. Types are Spooky (or: How Types Work)
Types live in a separate world set apart from the "concrete variables" world. Think of it as the "upside-down" of types.
If you try to declare both a concrete variable and a type with the same name, they won't clash, since they live in separate worlds.
const x = 0;
type x = number; //this is ok!
Types are declared by either the type
or the interface
statements. While those constructs may have peculiarities in syntax, just consider they are just ways to declare types. In the end a type will just represent some structure, regardless of which of the 2 statements you used to declare it*.
interface Animal {
weight: number;
}
// the word "interface" may be misleading.
// In TS, "interface" just means representing a JS object type
// since it is just a JS object, any property type is allowed, not just methods
Types are immutable
You can't ever modify a type, but you can always create a new type based on another existing one;
interface Cat extends Animal {
isCatnipped: boolean;
}
type MeowingCat = Cat & { meow(): void };
// We have
// - created new types based on existing ones
// - both "extends" and "type intersection (&)" syntaxes ended up performing the
// same structural operation: adding a new property the type
A purpose in life
The final purpose of a type is to be linked to a concrete "living" variable, so its sins can be checked by the compiler.
const myFatCat: MeowingCat = {
weight: 2.4,
iscatnipped: false, //error!!
meow() {
performMeow();
}
};
What if I don't assign a type to a variable?
- Every variable will always have a type. If I don't explicitly assign a type, the compiler will then infer one from the initial assignment; On VSCode, one can easily check the type of anything by mouse-overing.
const barkingFatCat = {
...myFatCat,
bark() {
throw Error("bark not found");
}
};
// will have weight, iscatnipped, meow and bark properties
An important advice about working with typescript: **mouseover everything**. Every variable. Every time. Extensively.
Seriously, you can do a big bit of "debugging" just by carefully inspecting every variable's inferred type.
A lifelong link
- One variable can only have one type during its whole lifespan. However, you can still create new variables and do casts;
Going the other way
- The inverse operation -- retrieving a type from a variable -- is possible with the
typeof
statement.type StrangeCat = typeof barkingFatCat
.
5. Mutable code and types
Because of the properties listed above, some patterns that you might be used to in JS may not work well on a static type system. For instance, let's say one would create an object like this:
const person = {};
person.name = "John"; // error!
person.lastName = "Wick";
TS will complain since person
is declared by inference to be of type "empty object". Therefore, person
can't accept any properties.
There are many ways we could adapt our code to tackle this problem. The most recommended one is: build the final object in one step, composing its parts.
const person2 = {
name: "John",
lastName: "Wick"
}; // OK!
Other more verbose way is pre-declaring the object type. This is not ideal though, since we are repeating ourselves.
interface Person {
name?: string;
lastName?: string;
}
const person3: Person = {};
person3.name = "John";
person3.lastName = "Wick";
If you are having a hard time typing something, you can always assign a variable to any
, disabling all type-checking on it.
const person4: any = {};
person4.name = "John";
person4.last.name = "Wick"; // this won't type-error, even if wrong
On the productive use of any
and other loose types
Every time a developer assigns any
to a variable, it acknowledges that TS will stop checking it, facing all the consequences this may bring.
While it's not advisable to use any
, sometimes it can be hard to correctly set the type of a variable, especially when learning the language - or even when facing its limitations. Using any
is not a crime and sometimes is necessary and productive. One should balance between not using any
excessively but also not to spend much time trying to fix a type error.
6. Syntax primer: Primitive types
- All primitive types are referenced in lowercase.
number
,string
,boolean
,undefined
,null
... - TS adds a couple of extra lowercase types solely related to its type-checking job:
any
,unknown
,void
,never
... - Arrays can be declared either by
something[]
orArray<something>
;
Be careful: There also exists an uppercase
Number
type, which is a different thing from lowercasenumber
! Types likeNumber
,String
,Boolean
refer to the javascript functions that have those names.Be careful: Both types
{}
andobject
refer to an empty object. To declare an object that can receive any property, useRecord<string, any>
.
Strict nulls
- Unlike some other languages, types do not implicitly include
null
; - Ex: in Java, any variable can always also be null;
- In TypeScript a type is declared as nullable through a type union:
type X = Something | null | undefined
- A type can be narrowed as "not null" through control flow analysis. Ex:
const x = 2 as number | null
if (x) {
console.log(x) // x cannot be null inside this block
}
- You can tell the compiler to assume a variable is not null with the
!
operator;
interface X {
optional?: { value: number }
}
const instance: X = {}
console.log(instance.optional.value) // TS will show error
console.log(instance.optional!.value) // assume "optional" exists
7. Interfaces vs. Type Aliases
- Which one to use? Whatever... both declare types! It is complicated.
-
Type aliases can receive other things than objects; Most noticeable exclusive to those are:
- Type unions and intersections;
- Conditional types;
-
Interfaces work exclusively with objects (functions are also objects!). Exclusive to interfaces are:
- The OOPish
extends
clause, which is somewhat similar to the type intersection of two objects; - Declaration merging. When you declare 2 interfaces with the same name, instead of clashing, their properties will merge. (They can still clash if their properties are incompatible, of course);
- Common use of declaration merging: Add another property to the global DOM's
Window
declaration.
- The OOPish
interface Animal {
name: string
isDomestic?: boolean // optional property, receives type boolean|undefined
readonly sciName: string // forbids mutation. Notable sample: react's state
yell(volume: 1 | 2 | 3 ): void
// - types can receive constants (1 | 2 | 3)
// - the "void" type is mostly only used in function returns, and
// has subtle differences from undefined
(): void
// declare this object as "callable" - this is hardly ever used.
new (): Animal
// declare this object as "newable" - this is hardly ever used.
}
interface Cat extends Animal {
isDomestic: true // narrows down parent's `isDomestic`
meow(): void; // additional property
}
// merges with the interface above
interface Cat extends Animal {
purr(): void
}
Type alias sample below. Almost the same capabilities and syntax.
type SomeCallback = (i: string) => number
type DiscriminatedUnion = { type: 'a', data: number } | { type: 'b', data: string }
type Animal = {
name: string
isDomestic?: boolean
readOnly sciName: string
yell(volume: 1 | 2 | 3 ): void
(): void
new (): Animal
}
type Cat = Animal & {
isDomestic: true
meow(): void
}
// declaration merging not possible
8. Class: a creature that spans both worlds
Classes in TypeScript have a few extra features compared to JS classes, mostly related to type-checking.
- You can declare uninitialized properties on the class body; Those don't generate JS code, they just declare types for checking.
- If a property is not initialized on the constructor, or directly, TS will complain. You can either declare a property as optional (append
?
) or assume it is not null (append!
).
class Foo {
constructor(name: string) {
this.name = name
}
name: string
hasBar?: string
certainlyNotNull!: number
}
- Access modifiers (
private
,protected
andpublic
) are a thing; Yet again, they only serve as hints to the type-checker. Aprivate
declared property will still be emitted and visible in JS code. - Class fields can be initialized in-body (same as JS, recent-y proposal);
class Foo {
// ...
private handleBar() {
return this.name + (this.hasBar || '')
}
init = 2;
}
- Unique to TS, you can add modifiers to constructor parameters. This will act as a shorthand that copies them to a class property.
class Foo {
constructor(private name: string) {} // declares a private property "name"
}
Both worlds
The class
statement differs from most others are it declares both a variable and a type. This is due to the dual nature of JS/OOP classes (a class actually packs 2 objects inside one definition).
class Foo {}
type X = Foo // "Foo - the type" will have the INSTANCE type
type Y = typeof Foo // Y will have the PROTOTYPE type
// (when writing typeof, "Foo" refers to the "living foo",
// which in turn is the prototype)
type Z = InstanceType<Y> // the inverse operation
var foo = new Foo() // "Foo" exists in both worlds;
9. Structural typing and you
In order to determine if two types are assignable, the compiler exhaustively compares all their properties.
This contrasts with nominal typing, which works like:
Two types only are assignable if they were created from the same constructor OR from an explicitly related constructor. (explicitly related usually means: extends or implements).
Given two classes A and B:
class A {
name
lastName
}
class B {
name
lastName
age
}
Now let a function require A as input.
function requireA(person: A) {}
requireA(new A()) //ok
requireA(new B()) //ok
requireA({ name: 'Barbra', lastName: 'Streisand' }) //ok
requireA({ name: 'Barbra', lastName: 'Streisand', age: 77 }) //error
- The function accepted B as input since its properties were considered assignable;
- This would not be allowed on nominal typing, since it would require
B
to explicitlyextend
orimplement
A
; - Since we are just comparing properties, just directly passing a conforming object also works;
- The last line errors because TS applies a special rule which enforces exact properties if the argument is a literal;
10. Control flow analysis
At each instruction, a variable's type may be narrowed according to the current context.
function cfaSample(x: number|string) {
console.log(x) // : number|string
if (typeof x === 'string') {
console.log(x) // : string
return x
}
return [x] // [number]
} // inferred return type: string|[number]
- Some expressions (
typeof x === 'string'
) act as "type guards", narrowing the possible types of a variable inside a context (the if statement); -
x
is narrowed fromnumber|string
tostring
inside the if block; -
x
only can bynumber
at the last line, since theif
block returns; - The function gets an inferred return type corresponding to an union of all return paths;
Discriminated union
- The type
Actions
below is called a discriminated union . The propertytype
is used as a tag to filter out which of the union options is valid at the context; - At each
case
line below,action.data
has its type narrowed down;
type Actions =
| { type: "create"; data: { name: string } }
| { type: "delete"; data: { id: number } }
| { type: "read"; data: number }
function reducer(action: Actions) {
switch(action.type) {
case 'create':
return createFoo(action.data) // data: {name: string}
case 'delete':
return deleteFoo(action.data) // data: {id: number}
case 'read':
return readFoo(action.data) // data: number
}
}
11. More advanced type syntaxes for another day
(A veryfast reference overview below. Don't worry if you don't understand something, just know that those exist, so you can research later.)
- Mapped types is a syntax used to declare generic objects.
type GenericObject = {
requireMe: number
[k: string]: any
}
// GenericObject CAN have any property and MUST have `requireMe`
- Mapped types can be used to remap one object type to another, by iterating over its keys.
-
keyof
lists all possible keys of an object type as a type union;
type Dummy = {
a: string
b: number
}
type Mapped = {
[k in keyof dummy]: { value: dummy[k] }
}
// wraps Dummy's values into a { value: x } object
- Properties may me accessed with
[""]
type X = Dummy['a'] //will return `string`
- Conditional types were created to solve a dozen of the type system's limitations. Its name may be misleading. One of the dozen things conditional types can do is to "pick" a type from inside another type expression. For instance:
type Unwrap<T> = T extends Promise<infer R> ? R : never
type X = Unwrap<Promise<number>> // X will be 'number'
// this sample also uses generics, which we will cover soon
- The standard type lib includes some auxiliary type aliases like
Record
andOmit
. All of those type aliases are made by composing the features previously shown. You can check all available helpers and its implementation by CTRL+Clicking any of them.
type DummyWithoutA = Omit<Dummy, 'a'>
When you want to dig deeper, I'd strongly recommend checking the Typescript playground samples session.
12.Generics
Roughly saying, generics are types which can receive type parameters. Like every other type related feature shown, it does not emit any extra JavaScript output.
interface GenericInterface<Data> {
content: Data
}
type FunctionOf<X, Y> = (i: X) => Y
// functions and classes can also receive type parameters.
function makeData<Input>(i: Input) {
return { data: i }
}
function cantInfer<Output>(i: any): Output {
return i
}
class GenericClass<Input> {
constructor(public data: Input) { }
}
- A type parameter can receive a default type, making it optional.
function hello<X = string>() {
return {} as any as X
}
Argument inference
- A generic function will, at first, require that you supply its type parameters;
cantInfer(2) // error
cantInfer<string>(2) //okay
- If the type parameter has a default value, it is not required;
hello() //ok
hello<Promise>() //ok
- If type parameters are referenced in function arguments and NO type parameters are passed on call, TS will try to infer them from the arguments;
function makeData<Input>(i: Input) {
return { data: i }
}
makeData(2) // Input gets inferred to `number`
// return type is inferred to { data: number }
makeData<string>(2) // will raise an error since type parameter
// and argument are incoherent
Bounded type parameters
- A type argument can have constraints;
function acceptObject<Input extends { x: number }>(i: Input) {
return i
}
acceptObject({}) // error, must at least have x
acceptObject({ x: 2, y: 3 }) // ok, and returns { x, y }
13. Modules
TypeScript is made to adapt to JavaScript. And JavaScript itself has had many module systems for different environments and times. Most notably:
- The browser console "vanilla" environment is module-less. Every imported file lives in the global scope;
- node.js traditionally uses the "commonjs" module syntax;
- Modern front-end code built with module bundlers usually use the "es-modules" syntax;
Module-less typescript
- A TypeScript file is considered module-less if it has no imports or exports;
- All typescript source files share the same global context. Which is defined at the
include
entry of the tsconfig; - A file can manually include a reference through the addition of the "triple slash directive" at the first line. Shivers from the good-ol-triple-slash-directive-times?
///<reference path=“./path/to/file”/>
Moduleful typescript
- The TS import syntax comes from the es-module syntax;
- You can also write some additional syntax not covered by the es-modules:
import express = require("express") // enforce commonjs import
const express = require("express") // this works BUT 3rd party types won't get imported
import * as express from 'express'
import express from 'express' // only works with "esModuleInterop"
export = { something: 'x' } // "module.exports =" syntax from commonjs
14. 3rd party types
One can usually obtain types from 3rd party libraries through the following means:
- The library itself publishes
.d.ts
definitions along with the package, referencing it on thetypings
key of package.json; - Someone publishes types for the library at the DefinitelyTyped repository, available through npm
@types/<lib>
; - There are methods for manually declaring a 3rd party library's types inside the consumer project;
What if the library does not have types?
- The library will be imported as
any
but you can continue to use it as-is; - If
noImplicitAny
is turned on, adeclare "library"
entry must be declared in a global file;
3rd party typescript types are also used to power JS type completion in VS Code.
Thats it!
And that was only supposed to be an introduction! Thank you!
Recommended links:
On a future chapter maybe:
- Domain specific things; React + TS? node + TS?
- Writing type definitions.
Top comments (0)