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
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
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
}
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)
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
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
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
}
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') => { }
})
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') => { }
})
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.
Top comments (0)