Published on August 03, 2025

Typescript tips

 15 minutes

TypeScript has quickly become one of the most popular and powerful tools for modern JavaScript development. As its adoption grows, so does the need for developers to master its more advanced features and best practices. That’s where Effective TypeScript comes in—a comprehensive guide that dives deep into how to write clean, maintainable, and robust TypeScript code.

In this article, I’ll be sharing some of the key takeaways and tips I’ve learned from the book Effective TypeScript. Whether you’re new to TypeScript or an experienced user, these tips will help you harness the full potential of the language and avoid common pitfalls. From improving your type safety to writing more expressive code, these insights will empower you to level up your TypeScript skills and write better code with confidence.

Configure Typescript preferably using a config file rather than via command line

The tsconfig.json clearly communicates your project's setup to other developers and tools. It is version controlled, so your configuration is shared across your team.

Settings

The Typescript compiler includes several settings which affect the core aspects of the language. In order to control core aspects of the language, we should understand these couple of settings: noImplycitAny and strictNullChecks

The option noImplicitAny dictates if any variable must have known types. For instance, this code is valid if noImplicitAny is off:

function substract(a, b){ return a - b }

It is recommended to always have the noImplicitAny config attribute on, only under specific circumstances it is justified to disable it. For example, if you're transitioning an existing project from Javascript to Typescript.

The option strictNullChecks controls whether null and undefined are allowed values for types, e.g:

const x: string = null // this is valid

It is recommended to use strictNullChecks to prevent undefined is not an object runtime errors.

Use type declarations rather than type assertions.

You can assign values to a variable in two different ways:

// type declaration interface Point { x: number, y: number } const point : Point = { x: 20, y: 20 } // type assertion const point = { x: 20, y: 20 } as Point

With a type declaration you're ensuring the value conforms to the type, whereas with a type assertion you're basically telling the compiler that depite the type it inferred you know better.

Avoid Typescript object wrapper types. Instead, use primitives ones

When you write:

let name: String; let count: Number; let flag: Boolean

You're not using the primitive types. You're referring to boxed object wrappers, which are the same ones created via constructors like new String("abc") and they behave differently and incorrectly in most cases.

Why to avoid them ?

  • They add unnecessary indirection

    typeof "hello" // string typeof new String("hello") // object
  • They can lead to runtime bugs.

  • Confusing and rarely what you want

    Most Javascript functions expect primitive values like string, not boxed objects.

Prefer using types over interfaces:

Types are better for composition:

type A = { x: number }; type B = { y: number }; type C = A & B; // intersection

With type, you can easily compose types using & (intersection ) and | (union), which is much harder with interfaces.

WIth type, you can also describe more things:

type Status = "pending" | "success" | "error"

Use interfaces is if you need augmentation, a.k.a merging :

interface A { x: number }; interface A { y: number }; // merges into one A

Use interface when:

  • You're describing object shapes, especially for classes or library public APIs.
  • You want declaration merging.
  • You work in a team or ecosystem ( like React ) that conventionally uses interfaces.

Index signatures

In Typescript, index signatures let you define the shape of an object when you don't know all the property names in advance, but you know the types of the keys and values:

interface Something { [key: string]: number; }

This means:

  • Any string key is allowed

  • The value for each key must be a number;

    Use index signatures when:

    • You're working with objects that have dynamic property names.
    • You know all the values are of a consistent type, even if keys vary.
    • You don't want or can't define all possible names up front.

Mapped types

Use Mapped types to avoid duplicating logic between values ( like keys of an object ) and their associated types. This helps keep the code DRY, type-safe and maintainable. e.g:

const roles = { admin: 'admin', user: 'user', guest: 'guest' } type Role = keyof typeof roles // automatically 'admin' | 'user' | 'guest'

Now, Role always reflects the keys of roles - fully synchronized.

This way there's a single source of truth, automatic updates when the object changes and fewer bugs from stale or mismatched types.

Readonly

Similarly to how other programming languages work (like Rust), in Typescript it is recommended to treat mutability as the exception, not the default.

By making values unmmutable by default, readonly leads to a safer and more predictable code, and prevents accidental mutations.

Readonly should be used:

  • On properties in object types:

    type User = { readonly id: string; name: string; }
  • With arrays and tuples:

    const directions: readonly string[] = [ "N", "S", "E", "W"];

    Or

    const pair = [1, 2] as const; // inferred as readonly [1, 2]
  • With mapped types:

    type ReadOnlyUser = ReadOnly<User>;
  • Functions params:

    You can prevent a function from mutating inputs:

    function greet(user: ReadOnly<User>){ user.name = "Bob" // Error }

Excess Property Checking

Typescript performs extra validation when you assign an object literal directly to a variable or pass it directly as a function argument. It checks for extra properties that don't exist on the target type and errors if it finds any.

type User = { name: string; age: number; } const user : User { name: "tom", age: 25, role: "admin" // Error: Object literal may only specify known properties }

The above fails because role is an excess property - not declared in User.

It is recommended not to bypass excess property checks unless you're really sure that's what you want.

They are there for your protection, not to annoy you. Use them to catch real mistakes, like mispelled props or unwanted fields in configuration objects.

Utility Types

The following utility types, are part of the standard library, and they're extremelly useful for transforming existing types without repeating yourself:

  • Partial

    Makes all properties optional. For example note the following type:

    type Person { id: number; name: string; }

    You could define a another type and make all its properties optional:

    type User = Partial<Person>; // Now it's equivalent to type User { id?: number; name?: string; }

    Use it when:

    • You're updating part of an object (e.g., PATCH APIs).
    • You want to allow constructing incomplete versions of a type.
  • Pick<T,K>

    Creates a new type by picking a subset of properties from T.

    type User = { id: number; name: string; role: string; }

    Using Pick

    type Person = Pick<User, "id" | "name">; // Result : type Person = { id: number; name: string; }

    Use it when:

    • You only need some fields.

    • You want to avoid manually redefining part of a type.

  • Record<K,V>

    Creates an object type with keys K and values of type V.

    type Role = "admin" | "user" | "guest"; type Permissions = Record<Role, boolean>; // Result : type Permissions = { admin: boolean; user: boolean; guest: boolean; }

    Use it when:

    • You want a dictionary-like structure with fixed keys.
    • You want type-safe object maps.
  • Omit<T, K>

    Creates a type by removing properties from T. e.g.:

    type User { id: number; name: string; pass: string; }

    When you use Omit:

    type Person = Omit<User, "pass">; // Result: type Person { id: number; name: string; }

    Use it when:

    • You want to hide or remove sensitive or unused fields.
  • Required

    Makes all properties required, even if they were optional.

    type User = { id?: number; name?: string; } type Person = Required<User>: // Result: type Person = { id: number; name: string; }

    Use it when:

    • You want to enforce full object completeness.
  • ReadOnly

    Makes all properties immutable (readonly).

    type User = { id: number; name: string; } type Person = ReadOnly<User>; // Result: type Person = { readonly id: number; readonly name: string; }

    Use it when:

    • You want to prevent mutation.

Avoid writing type annotations everywhere

Typescript's type inference is strong, so over-annotating leads to redundant, noisy, and sometimes even inconsistent code; You're not adding value by restating what Typescript already knows:

const count : number = 5; // unnecesary const count = 5; // better, inferred as number

When should you use type annotations:

  • Function parameters

    As far as parameters go the compiler can't infer them

  • Public APIs (exports, props)

    For clarity and contract definition

  • Function return types

    Improves readability and enforces intent

  • Generic function or complex types

    Inference can fall short here

Widening

Widening is when Typescript takes a literal type and converts it into a broader type to allow more flexibility - usually when you declare a variable with let or var and don't give it an explicit type

let x = "hello" // typescript widens the type to x:string

Now using const:

const y = "hello" // Now y does get the literal "hello" because it's not going to change

Now with null or undefined:

let z = null // z has type 'any' (in older versions) or null | undefined in stricter configs

It is recommended to:

  • Use const

    const direction = "left" // direction: "left"
  • Use explicit literal types:

    let direction: "left" | "right" = "left"
  • Use as const:

    const config = { direction: "left" } as const; // makes direction : "left" instead of string

Narrowing

Narrowing is the opposite of widening. In Typescript variables can have union types, the language allows you to narrow them down to a specific type. e.g.:

function print( value : string | number ) { if (typeof value === 'string'){ console.log( value.toLowercase() ) } else { console.log( value.toFixed(2) ) } }

it is recommended to:

  • Use typeof, instanceof or custom body guards to narrow types.
  • Limit or directly avoid using any or undefined
  • Use discriminated unions ( tagged unions ), in order to allow safety narrowing.

Prefer union of interfaces over interfaces of unions

When you see an interface where multiple properties are union types, like this:

interface Shape { kind: "circle" | "square": radius?: number; sideLenght: number; }

At first glance it looks nice, but actually it's problematic because it doesn't prevent you from creating invalid combinations:

const badShape: Shape = { kind: "circle", sideLength: 10 // shouldn't exist on circle }

Typescript won't catch any of those mistakes unless you add extra checks, because the union is spread across properties instead of being modeled as mutually exclusive types.

It is recommended to:

  • Use a discriminated union ( a.k.a. tagged union ):

    interface Circle { kind: "circle"; radius: number; } interface Square { kind: "square"; sideLength: number; } type Shape = Circle | Square;

    Now the type system enforces that only valid combinations exist:

    const c : Shape = { kind: "circle", radius: 5 } // good const s : Shape = { kind: "square", sideLength: 10 } // good const b : Shape = { kind: "circle", sideLength: 10 } // type error

    If an interface has several properties that are union-typed, and only certain combinations make sense, that's usually a sign you should refactor into a discriminated union.

Avoid usig plain string type when you can be more precise

The reason to do this is that string means any possible string value, which loses a lot of type safety. Instead, you can use more specific alternatives depending on the situation. e.g:

type Direction = "up" | "down" | "left" | "right"; function move(dir: Direction) { ... } move("up"); // Ok move("forward") // Error not assignable to Direction

Here, instead of string, we restrict the allowed values.

The idea is to prefer string literals, unions, template literals, enums, or branded types instead of plain string when the domain of possible values is more restricted.

Generate types from specs, not data.

When you get the types from actual data (JSON samples), you risk:

  • Incompleteness:

    Your sample may not represent all possible values.

  • False confidence:

    If the API later returns a field you don't see in your sample, your types won't catch it.

  • Fragile code:

    Your types are tied to a particular dataset instead of the real contract.

Instead, types should be generated from the API specification (OpenAPI/Swagger, GraphQL Schema, gRPC protocol, etc) because:

  • The spec is the source of truth of what the API promises to return.
  • Your types will stay aligned with API contract even if the data you've seen doesn't expose all cases.

Migrating to Typescript

The keys recommendations for migrating to Typescript are about gradual adoption, minimizing friction, and making Typescript work with your existing JS ecosystem intead of trying to rewrite everything at once:

  1. Start with allowJs and checkJs
    • Don't try to migrate all files at once
    • Use allowJs so .js files can coexist with .ts files
    • Use checkJs to let Typescript type-check plain Javascript files progressively.
  2. Use JSDoc for gradual typing
    • Add JSDoc type annotation in Javascript before renaming files to .ts.
    • TS understands JSDoc types, so you can improve type safety while keeping JS files untouched.
    • This is a safe first layer of type checking.
  3. Convert incrementally
    • Pick modules with fewer dependencies or stable parts of the codebase to migrate first.
    • Don't migrate the whole project in one shot.
    • You can convert file-by-file from .js to .ts once the types are clear.
  4. Start with looser compiler settings
    • Begin with permissive options like noImplicitAny:false to avoid being overwhelmed by errors.
    • Gradually tighten the compiler flags (strict:true, noImplicitAny:true) as the migration progresses.
    • Focus on progressive strictness
  5. Type external libraries
    • Use DefinitelyTyped (@types/...) for external JS libraries.
    • If types don't exist, create minimal tyope definitions (.d.ts files) only for the parts you use.
    • Don't waste time fully typing huge libraries upfront.
  6. Focus on API boundaries
    • Prioritize adding types to function parameters ande return types that cross module boundaries.
    • Internal implementation details can remain loosely typed until later.
    • This way, consumers of your code are protected early.
  7. Lean on any but don't abuse it
    • Use any ( or unknown ) as a temporary escape hatch.
    • Mark uncertain types explicitly so you knpow what to revisit later.
    • Over type, replace them with real types.
  8. Test alongside migration
    • Keep your test suit running so you don't rely only on Typescript for correctness.
    • Migration should improve safety, not replace testing.

So the core idea, in that migrating should be incremental, low friction, and reversible, avoiding big-bang rewrites. Start small, keep the compiler permissive, and slowly add type coverage and stricter checks.

Comments