DEV Community

yossarian
yossarian

Posted on

How to write a bit safer types in TypeScript

In this article you will learn:

  • how to make your typescript functions even more safer
  • some tricky differences between types and interfaces
  • how to make types for your data structure more safe

Part 1 - safer functions

Consider this example which is stolen from TypeScript docs:


type Animal = { tag: 'animal' }

type Dog = Animal & { bark: true }
type Cat = Animal & { meow: true }

declare let animal: (x: Animal) => void;
declare let dog: (x: Dog) => void;
declare let cat: (x: Cat) => void;

animal = dog; // ok without strictFunctionTypes and error with

dog = animal; // should be ok

dog = cat; // should be error
Enter fullscreen mode Exit fullscreen mode

Very simple code, nothing complicated.

Animal is a supertype for Dog and Cat.
There are a lot of typescript projects in the wild without strict flags. If you have active strictFunctionTypes flag, please disable it.

After disabling, you will see that animal = dog does not produces an error despite the fact that it isn’t provably sound.

This is unsound because a caller might end up being given a function that takes a more specialized type, but invokes the function with a less specialized type. In practice, this sort of error is rare, and allowing this enables many common JavaScript patterns

Of course if you have a big project you can't just turn on strictFunctionTypes and fix all errors.This is not always possible. So how to live without this flag?

Answer is simple. Just use generics.

type Animal = { tag: 'animal' }
type Dog = Animal & { bark: true }

// generic is here
declare let animal: <T extends Animal>(x: T) => void;
declare let dog: (x: Dog) => void;

animal = dog; // error even without strictFunctionTypes
Enter fullscreen mode Exit fullscreen mode

Almost forgot, prefer arrow function notation inside interfaces rather than method notation:

// unsafe
interface Bivariant<T> {
  call(x: T): void
}

// safe
interface Contravariant<T> {
  call: (x: T) => void
}
Enter fullscreen mode Exit fullscreen mode

Part 2 - tricky differences between types and interfaces

Imagine you have untyped handleRecord function:

interface Animal {
  tag: 'animal',
  name: 'some animal'
}

declare var animal: Animal;

const handleRecord = (obj:any) => { }

const result = handleRecord(animal)
Enter fullscreen mode Exit fullscreen mode

You know that this function expects and object. You can replace any with object type, but eslint will not be happy about this change and will suggest you to use Record<string, unknown> instead.

interface Animal {
  tag: 'animal',
  name: 'some animal'
}

declare var animal: Animal;

const handleRecord = (obj:Record<string, unknown>) => { }

const result = handleRecord(animal) // error
Enter fullscreen mode Exit fullscreen mode

As you might have noticed, it still does not work. Because interfaces are not indexed by the default and we still can't pass Animal object to handleRecord. So, what we can do to fix our type and don't break dependent types?

Just use type instead of interface.


type Animal= {
  tag: 'animal',
  name: 'some animal'
}

declare var animal: Animal;

const handleRecord = (obj:Record<string, unknown>) => { }

const result = handleRecord(animal) // ok
Enter fullscreen mode Exit fullscreen mode

Part 3 - safer data structure
Consider this example:

interface Animals {
  dog: 'Sharky',
  cat: 'Meout'
}

type AnimalEvent<T extends keyof Animals> = {
  name: T
  call: (name: Animals[T]) => void
}
Enter fullscreen mode Exit fullscreen mode

Seems that AnimalEvent constructor type is perfectly fine. Yea, why not? Let's use it as a function argument or array element:

const handleEvent = <T extends keyof Animals>(event: AnimalEvent<T>) => { }

// we would expect an error but it compiles 
const arrayOfEvents: AnimalEvent<keyof Animals>[] = [{
  name: 'dog',
  call: (name: 'Meout') => { }
}]

// should be error but it compiles
handleEvent<keyof Animals>({
  name: 'dog',
  call: (name: 'Meout') => { }
})
Enter fullscreen mode Exit fullscreen mode

It is defenitely something wrong with our type because it allows us to represent invalid state. We all know that invalid state should not be representable if we use TypeScript.

Let's refactor it a bit:

interface Animals {
  dog: 'Sharky',
  cat: 'Meout'
}

type EventConstructor<T extends keyof Animals> = {
  name: T
  call: (name: Animals[T]) => void
}
/**
 * Retrieves a union of all possible values
 */
type Values<T> = T[keyof T]

// "Sharky" | "Meout"
type Test = Values<Animals>

// EventConstructor<"dog"> | EventConstructor<"cat">
type AnimalEvent = Values<{
  [Prop in keyof Animals]: EventConstructor<Prop>
}>

const handleEvent = (event: AnimalEvent) => { }

// error
const arrayOfEvents: AnimalEvent[] = [{
  name: 'dog',
  call: (name: 'Meout') => { }
}]

// error
handleEvent({
  name: 'dog',
  call: (name: 'Meout') => { }
})
Enter fullscreen mode Exit fullscreen mode

Instead of using generic for animal name we have created a union of all possible AnimalEvents representation. Hence - illegal state is unrepresentable.

These techniques are easy to use and simple to understand.

Discussion (0)