Skip to main content

TypeScript

TS

Why TypeScript?

  • Superset of JavaScript — all valid JS is valid TS
  • Adds static type checking at compile time — catch bugs before runtime
  • Better IDE support — autocompletion, refactoring, navigation
  • Self-documenting code — types serve as documentation
  • Compiles to plain JavaScript — types are erased at runtime

Setup — Vite

npm create vite@latest my-ts-app -- --template vanilla-ts

cd my-ts-app
npm install
npm run dev

code .

Write some code and then inspect the build result:

npm run build

::: tip Historically Webpack and Babel were used to compile TypeScript. Vite is the modern standard — faster, simpler configuration. :::

Type Checking

OK in pure JS — no errors, but unexpected results.

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

let ab = add("a", 5); // "a5" — string concatenation, not addition

TS catches multiple errors at compile time:

function add(a: number, b: number): string {
return a + b; // Error: Type 'number' is not assignable to type 'string'
}

let sum = 5;
sum = add("a", 5); // Error: Argument of type 'string' is not assignable to parameter of type 'number'

Full types defined — no errors:

function add(a: number, b: number): string {
return (a + b).toString();
}
let sum: string = "5";
sum = add(1, 5);

Or using type inference — TS infers return type as string and sum as string:

function add(a: number, b: number) {
return (a + b).toString();
}
let sum = "5";
sum = add(1, 5);

Basic Types

boolean, number, string, string interpolation

let isDone: boolean = false;

let decimal: number = 6;
let hex: number = 0xf00d;
let binary: number = 0b1010;
let octal: number = 0o744;

let fullName: string = `Andres Käver`;
let born: number = 1974;
let sentence1: string = `Hello, my name is ${fullName}.

I am ${2020 - born} years old.`;

let sentence2: string = "Hello, my name is " + fullName + ".\n\n" + "I am " + (2020 - born) + " years old.";

Basic Types – Array, Tuple

let list1: number[] = [1, 2, 3];
let list2: Array<number> = [1, 2, 3];

// Declare a tuple type
let x: [string, number];
// Initialize it
x = ["akaver", 1974]; // OK

// Error: Type 'number' is not assignable to type 'string'
x = [1974, "akaver"];

console.log(x[0].substring(1)); // OK

// Error: Property 'substring' does not exist on type 'number'
console.log(x[1].substring(1));

// Error: Tuple type '[string, number]' of length '2' has no element at index '3'
x[3] = "world";

Enum

  • Fixed set of values (typically numbers)
  • Values start from 0, can be specified
  • Provides mapping in both directions
  • String enums
    • No reverse mapping
enum Color1 {
Red,
Green,
Blue,
}
let c1: Color1 = Color1.Green;

enum Color2 {
Red = 1,
Green = 2,
Blue = 4,
}
let c2: Color2 = Color2.Green;

// enum reverse mapping
let colorName: string = Color1[c1];
let colorCode: number = Color1["Red"];

enum Names {
foo = "Foo",
bar = "Bar",
}
let n: Names = Names.foo;

Union Types

If a variable/parameter must accept several possible types — use union types with |.

let s: string | null | undefined = "foo";
s = null;
s = undefined;
s = 5; // Error: Type 'number' is not assignable to type 'string | null | undefined'

Union types are foundational in TS — used everywhere for nullable values, function overloads, and state modeling.

function formatInput(input: string | number): string {
if (typeof input === "string") {
return input.toUpperCase(); // TS knows input is string here
}
return input.toFixed(2); // TS knows input is number here
}

Literal Types

Allows using only predefined values. Combines naturally with union types.

type Easing = "ease-in" | "ease-out" | "ease-in-out";
type Duration = 100 | 200 | 400;

function animate(easing: Easing, duration: Duration) {
// only specific values allowed
}
animate("ease-in", 200); // OK
animate("linear", 300); // Error on both arguments

Any

  • Opt-out of type checking and let the values pass through compile-time checks.
  • Return to plain JS approach — type is associated with value. No type checking by TS compiler.
  • Useful when working with external sources (json, html, libraries)
let notSure: any = 4;

// ifItExists might exist at runtime
notSure.ifItExists();

// toFixed exists (no compiler check)
notSure.toFixed();

notSure = "maybe a string instead";
// definitely a boolean
notSure = false;

let list: any[] = [1, true, "free"];

Unknown

  • Safer alternative to any — you must narrow the type before using it.
  • Preferred over any when the type is genuinely unknown (API responses, JSON parsing, error handling).
let value: unknown = "hello";

// Error: Object is of type 'unknown' — cannot use directly
value.toUpperCase();

// Must narrow the type first
if (typeof value === "string") {
value.toUpperCase(); // OK — TS knows it's a string
}

// Common pattern: error handling
try {
// ...
} catch (e: unknown) {
if (e instanceof Error) {
console.log(e.message); // OK — narrowed to Error
}
}

Void

Opposite of any. Usually used as return type from functions that don't return anything.

function testVoid(): void {
console.log("foobar");
// only undefined is allowed
return undefined;
}

let voidResult = testVoid();
console.log(voidResult); // undefined
voidResult = undefined;
voidResult = null; // Error: Type 'null' is not assignable to type 'void'

null, undefined

  • In TypeScript, both undefined and null actually have their own types named undefined and null respectively.
  • With strict mode (recommended), null and undefined are only assignable to any, unknown, and their respective types (the one exception being that undefined is also assignable to void).
  • Use union types to explicitly allow null/undefined: string | null

never

  • The never type represents the type of values that never occur.
  • Return type for functions that always throw or never return.
  • Variables acquire the type never when narrowed by type guards that can never be true.
// Function returning never must have unreachable end point
function error(message: string): never {
throw new Error(message);
}

// Inferred return type is never
function fail() {
return error("Something failed");
}

// Function returning never must have unreachable end point
function infiniteLoop(): never {
while (true) {}
}

object

object is a type that represents the non-primitive type, i.e. anything that is not number, string, boolean, bigint, symbol, null, or undefined.

declare function create(o: object | null): void;
create({ prop: 0 }); // OK
create(null); // OK
create(42); // Error
create("string"); // Error
create(false); // Error
create(undefined); // Error

Type Assertions (Casting)

  • Sometimes you know more than TS compiler. :-)
  • Type assertions are a way to tell the compiler "trust me, I know what I'm doing." A type assertion is like a type cast in other languages, but performs no special checking or restructuring of data. It has no runtime impact, and is used purely by the compiler.
  • as-style is the only one supported in JSX/TSX.
let someValue: any = "this is a string";
let strLength1: number = (<string>someValue).length;
let strLength2: number = (someValue as string).length; // preferred

Non-null Assertion Operator !

  • Tells the compiler that a value is definitely not null or undefined.
  • Use when you are certain a value exists but TS cannot prove it.
  • Use sparingly — it bypasses safety checks.
let element = document.getElementById("app"); // Type: HTMLElement | null

// Error: Object is possibly 'null'
element.innerHTML = "hello";

// Non-null assertion — you guarantee it exists
element!.innerHTML = "hello";

// Safer alternative — check first
if (element) {
element.innerHTML = "hello"; // TS narrows to HTMLElement
}

Type Aliases

Type aliases create a new name for a type. Type aliases are sometimes similar to interfaces, but can name primitives, unions, tuples, and any other types.

type Name = string;
type NameResolver = () => string;
type NameOrResolver = Name | NameResolver;

function getName(n: NameOrResolver): Name {
if (typeof n === "string") {
return n;
} else {
return n();
}
}

When to use type vs interface:

  • Use interface for object shapes — they can be extended and implemented by classes
  • Use type for unions, primitives, tuples, and complex type compositions

Interfaces

  • TS type checking focuses on the shape that values have. This is sometimes called "duck typing" or "structural subtyping".
  • In TS, interfaces fill the role of naming these types, and are a powerful way of defining contracts within your code as well as contracts with code outside of your project.
interface Person {
name: string;
}
function printLabel(p: Person) {
console.log(p.name);
}
let myPerson = { name: "akaver", age: 10 };
printLabel(myPerson);

Interfaces — Optional and Readonly Properties

  • Not all properties of an interface may be required. Some exist under certain conditions or may not be there at all.
  • Optional property — denoted by a ? at the end of the property name.
  • Readonly property — put readonly before the property name. Can only be set when the object is first created.
  • TypeScript comes with a ReadonlyArray<T> type with all mutating methods removed.
  • Rule of thumb: variables use const, properties use readonly.

Interfaces — Excess Property Checks

  • Object literals get special treatment and undergo excess property checking when assigning them to other variables, or passing them as arguments.
  • If an object literal has any properties that the "target type" doesn't have — error.
  • Use index signature to allow unknown properties.
interface Person {
readonly name: string;
age?: number;
[propName: string]: any;
}

function printLabel(p: Person) {
console.log(p.name);
}

printLabel({ name: "akaver", foo: "Bar" });

Interfaces — Function Types

  • To describe a function type within an interface, give the interface a call signature.
  • This is like a function declaration with only the parameter list and return type given.
  • Each parameter in the parameter list requires both name and type.
interface SearchFunc {
(source: string, subString: string): boolean;
}

Classes

  • Traditional JS does not have classes — they were introduced with ES6 (2015). TS has always supported them.
  • Few changes compared to ES6
    • Declare basic properties
    • Public, private (and JS # private) and protected modifiers

Classes – public, private, protected

  • Public by default.
  • Private — cannot be accessed outside from its containing class.
  • The protected modifier acts much like the private modifier with the exception that members declared protected can also be accessed within deriving classes.
  • TS is a structural type system. Comparing two different types, regardless of where they came from, if the types of all members are compatible — types themselves are compatible.
  • Comparing types that have private and protected members — for types to be considered compatible private members have to be originated in the same declaration. The same applies to protected members.

Classes – readonly, parameter properties

  • Properties can be made readonly by using the readonly keyword.
  • Readonly properties must be initialized at their declaration or in the constructor.
  • Parameter properties let you create and initialize a member in one place.
  • Parameter properties are declared by prefixing a constructor parameter with an accessibility modifier or readonly, or both.
class Point {
private x: number;
private y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}

// vs Parameter properties — much shorter

class Point {
constructor(
public x: number,
public y: number
) {}
}

Classes – Static, Abstract

  • Static members are visible on the class itself rather than on instances.
  • Each instance accesses this value through prepending the name of the class.
  • Abstract classes are base classes from which other classes may be derived. They may not be instantiated directly.
  • Unlike an interface, an abstract class may contain implementation details for its members.
abstract class Shape {
abstract area(): number; // must be implemented by subclasses

describe(): string {
return `This shape has area ${this.area()}`;
}
}

class Circle extends Shape {
constructor(private radius: number) {
super();
}

area(): number {
return Math.PI * this.radius ** 2;
}
}

// const s = new Shape(); // Error: Cannot create an instance of an abstract class
const c = new Circle(5);
console.log(c.describe()); // "This shape has area 78.53..."

Interfaces — Class Implementation

  • One of the most common uses of interfaces — explicitly enforcing that a class meets a particular contract.
  • Like classes, interfaces can extend each other.
  • An interface can extend multiple interfaces.
interface ClockInterface {
currentTime: Date;
setTime(d: Date): void;
}

class Clock implements ClockInterface {
currentTime: Date = new Date();
setTime(d: Date) {
this.currentTime = d;
}
constructor(h: number, m: number) {}
}

Functions

Function type:

let myAdd: (baseValue: number, increment: number) => number
= function (x: number, y: number): number
{
return x + y;
};
  • Optional and Default Parameters
    • TS requires that parameter types and their count match (not like JS)
  • Optional parameter — add ? at the end of parameter name.
  • Any optional parameters must follow required parameters.
  • Default value that a parameter will be assigned if the user does not provide one, or if the user passes undefined in its place — default-initialized parameters.
  • Default-initialized parameters that come after all required parameters are treated as optional

Functions – this parameter

  • In JavaScript, this is a variable that's set when a function is called. This makes it a very powerful and flexible feature, but it comes at the cost of always having to know about the context that a function is executing in.
  • Provide an explicit this parameter. this parameters are fake parameters that come first in the parameter list of a function.
function numberPressed(this: GlobalEventHandlers, event: Event) {
let elem = this as HTMLAnchorElement; // type assertion
let key = elem.dataset.value;
display!.innerHTML = calcBrain.handleKey(key!); // non-null assertions
}

Generics

Generics are similar to C# generics — create reusable components that work with multiple types.

function identity<T>(arg: T): T {
return arg;
}

let output1 = identity<string>("hello"); // explicit type
let output2 = identity(42); // type inferred as number

Generic Constraints

Use extends to constrain what types are allowed:

interface Lengthwise {
length: number;
}
function loggingIdentity<T extends Lengthwise>(arg: T): T {
console.log(arg.length);
return arg;
}
loggingIdentity({ length: 10, value: 3 }); // OK — has length
loggingIdentity(42); // Error: number doesn't have .length

Advanced Types — Intersection

Intersection types glue types together — the result has all properties from both types.

interface HasName {
name: string;
}
interface HasAge {
age: number;
}

type Person = HasName & HasAge;

let p: Person = { name: "akaver", age: 30 }; // must have both

Advanced Types — Type Guards

When working with union types, you need to differentiate between types. Repeated type assertions are not ideal:

interface Bird {
fly(): any;
layEggs(): any;
}
interface Fish {
swim(): any;
layEggs(): any;
}
function getSmallPet(): Fish | Bird {
//...
}
let pet = getSmallPet();
if ((pet as Fish).swim) {
(pet as Fish).swim();
} else if ((pet as Bird).fly) {
(pet as Bird).fly();
}

Using type predicates

Define a custom type guard function with is keyword:

function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}

if (isFish(pet)) {
pet.swim(); // TS knows pet is Fish
} else {
pet.fly(); // TS knows pet is Bird
}

typeof type guards

TS recognizes typeof checks and narrows types automatically:

  • typeof v === "typename" where typename is "number", "string", "boolean", or "symbol"

instanceof type guards

  • The right side of instanceof needs to be a constructor function
  • TS will narrow down to the type of that constructor's prototype

Discriminated Unions (Tagged Unions)

A common and powerful pattern — use a shared literal property to distinguish between union members. TS narrows the type based on the discriminant.

interface Circle {
kind: "circle";
radius: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
type Shape = Circle | Rectangle;

function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2; // TS knows it's Circle
case "rectangle":
return shape.width * shape.height; // TS knows it's Rectangle
}
}

This pattern is used everywhere in frameworks — Redux actions, API response states, component variants.

keyof and Indexed Access Types

keyof produces a union of an object type's property names:

interface Person {
name: string;
age: number;
}

type PersonKeys = keyof Person; // "name" | "age"

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

let person: Person = { name: "akaver", age: 30 };
let name = getProperty(person, "name"); // type: string
let age = getProperty(person, "age"); // type: number
getProperty(person, "email"); // Error: "email" is not in keyof Person

as const Assertions

as const makes values deeply readonly and narrows types to their literal values:

// Without as const — type is string[]
const colors = ["red", "green", "blue"];

// With as const — type is readonly ["red", "green", "blue"]
const colors = ["red", "green", "blue"] as const;

// Useful for configuration objects
const config = {
api: "https://api.example.com",
timeout: 5000,
} as const;
// config.api is type "https://api.example.com", not string
// config.timeout is type 5000, not number

// Common pattern: deriving types from values
const ROLES = ["admin", "user", "guest"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "user" | "guest"

Utility Types

TypeScript provides built-in utility types for common type transformations. These are used heavily in framework code.

interface User {
id: number;
name: string;
email: string;
age: number;
}

// Partial<T> — all properties become optional
function updateUser(id: number, updates: Partial<User>) {
// updates can have any subset of User properties
}
updateUser(1, { name: "new name" }); // OK — only name provided

// Required<T> — all properties become required (opposite of Partial)

// Pick<T, K> — select only certain properties
type UserPreview = Pick<User, "id" | "name">;
// { id: number; name: string }

// Omit<T, K> — remove certain properties
type UserWithoutEmail = Omit<User, "email">;
// { id: number; name: string; age: number }

// Record<K, V> — construct an object type with keys K and values V
type RolePermissions = Record<"admin" | "user" | "guest", boolean>;
// { admin: boolean; user: boolean; guest: boolean }

Declaration Files .d.ts

  • When using JavaScript libraries in TypeScript, you need type definitions.
  • Declaration files (.d.ts) provide type information without implementation.
  • Many popular libraries ship their own types. For others, install from DefinitelyTyped:
npm install --save-dev @types/lodash
npm install --save-dev @types/node
  • If no types exist, you can declare the module to suppress errors:
// global.d.ts
declare module "some-untyped-library";

tsconfig.json

Typical options for TS:

{
"compilerOptions": {
"module": "esnext",
"target": "es2017",
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"noImplicitThis": true,
"sourceMap": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"removeComments": true,
"moduleResolution": "node",
"lib": ["ES5", "ES6", "ES7", "ES2017", "ES2018", "ES2019", "ES2020", "ESNext", "DOM", "DOM.Iterable"]
},
"include": ["global.d.ts", "./src/**/*"],
"exclude": ["node_modules"]
}

Defence Preparation

Be prepared to explain topics like these:

  1. Why use TypeScript instead of plain JavaScript? What problems does it solve? — TypeScript adds static type checking, catching type-related bugs at compile time instead of at runtime. It provides better IDE support with autocompletion, refactoring tools, and inline documentation. It makes code more self-documenting because function signatures show what types are expected and returned. The type system also helps when working in teams, because interfaces define clear contracts between different parts of the codebase.
  2. What is the difference between any and unknown? Why is unknown safer?any disables all type checking — you can assign it to anything and call any method on it without errors. unknown is the type-safe counterpart: you can assign any value to an unknown variable, but you cannot use it without first narrowing its type (via typeof, instanceof, or a type guard). Use unknown when you receive data of uncertain type (e.g., from an API) and want to enforce proper validation before use.
  3. What is the difference between interface and type alias? When would you choose one over the other? — Both can describe object shapes, function signatures, and be extended. Interfaces support declaration merging (defining the same interface twice merges the definitions) and are the idiomatic choice for object shapes and class contracts. Type aliases support union types (string | number), intersection types, mapped types, and conditional types. Use interface for public APIs and object shapes; use type for unions, complex type transformations, and when you need features interfaces don't support.
  4. How do union types work, and how do you narrow them safely? — A union type (string | number) means the value can be any of the listed types. To use type-specific methods, you must narrow the type using typeof checks, instanceof, equality checks, or the in operator. TypeScript's control flow analysis understands these guards — inside an if (typeof x === 'string') block, x is treated as string. Discriminated unions use a shared literal property (e.g., type: 'circle' | 'square') for exhaustive pattern matching with switch.
  5. What are generics, and why are they useful? Give an example. — Generics let you write functions, classes, or interfaces that work with any type while preserving type safety. Instead of using any, you define a type parameter <T> that is determined when the function is called. Example: function first<T>(arr: T[]): T | undefined { return arr[0]; } — calling first([1, 2, 3]) infers T as number and the return type as number | undefined. Generic constraints (<T extends HasId>) restrict which types are accepted.
  6. What is keyof and how does indexed access work?keyof T produces a union of all property names (keys) of type T as string literals. T[K] (indexed access) gives you the type of property K on T. Together they enable type-safe property access: function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] — the compiler knows the return type based on which key you pass. This prevents accessing non-existent properties and provides correct return type inference.
  7. Name three TypeScript utility types and explain what they do.Partial<T> makes all properties of T optional, useful for update functions where you only provide changed fields. Pick<T, Keys> creates a type with only the specified properties from T, useful for selecting a subset of a larger interface. Omit<T, Keys> creates a type with all properties except the specified ones, useful for excluding sensitive or internal fields. Others include Required<T>, Record<K, V>, and Readonly<T>.
  8. What are discriminated unions (tagged unions), and why are they useful for TypeScript applications? — A discriminated union is a union of object types that share a common literal property (the "discriminant"), such as type: 'success' | 'error'. TypeScript can narrow the union based on this property in switch or if statements, giving you access to type-specific properties in each branch. They are useful for modeling states (loading/success/error), API responses, and any scenario where an object can be one of several shapes with different data.