Typescript notes

Few realisations:

  • In general objects with more properties are assignable to objects with less properties
    • But when objects are defined inline, TypeScript has extra rules
  • Object of a specific type could have additional properties. Properties are not limited to what is described in the type

Reading types of properties

type Subscription = {
  name: string;
  price: number;
  recurrence: "annual" | "monthly" | "weekly";
};

type Price = Subscription["age"];
// number

type Recurrence = Subscription["recurrence"];
// 'annual' | 'monthly' | 'weekly'

type NameOrPrice = Subscription["name" | "price"];
// string | number

The keyof keyword

keyof allows to generate a type from an object’s keys (properties).

type Subscription = {
  name: string;
  price: number;
  recurrence: "annual" | "monthly" | "weekly";
};

type SubscriptionKeys = keyof Subscription;
// 'name' | 'price' | 'recurrence'

You can easily create a helper type ValueOf allowing you to get all the possible types of object’s properties:

type ValueOf<Obj> = Obj[keyof Obj];
type SubscriptionValues = ValueOf<Subscription>;
//  string | number | 'annual' | 'monthly' | 'weekly'

JavaScript has typeof operator that gives you the type of an object. You can combine it with keyof to retrieve the keys of the object.

keyof typeof is a common pattern used to create types representing the keys of an object. It’s useful when you don’t know the type of the object. For example when you have an instance of the object created in place const obj = { name: 'Base plan'} . Consider example below:

const subscription = {
  name: "Base plan",
  price: 100,
  recurrence: "annual",
};
type SubscriptionKeys = keyof typeof subscription;
// 'name' | 'price' | 'recurrence'

Reading deep values of the nested object

If you want to check the types of the nested properties of the passed objects you can use the following notation:

export const getIn = <
  TObj,
  TKey extends keyof TObj,
  TNestedKey extends keyof TObj[TKey],
>(
  obj: TObj,
  key: TKey,
  nestedKey: TNestedKey,
) => {
  return obj[key][nestedKey];
};

const user = {
  address: {
    street: "420 9th Ave",
    country: "US",
    city: "New York",
  },
};

getIn(user, "address", "street");

The typeof keyword

For large, complex objects with deeper nesting levels, manually defining their types can be tedious.

const user = {
  address: {
    street: "420 9th Ave",
    country: "US",
    city: "New York",
  },
};

type User = typeof user;
type Address = User["address"];

To get more precise types you can use as const construct.

const address = {
  street: "420 9th Ave",
  country: "US",
  city: "New York",
};

typeof user;
/*
{
	street: string,
	country: string,
	city: string,
}
*/

const addressStatic = {
  street: "420 9th Ave",
  country: "US",
  city: "New York",
} as const;

typeof addressStatic;
/*
{
	readonly street: string,
	readonly country: string,
	readonly city: string,
}
*/

Optional properties

Sometimes you want to highlight that property can be missing on object and you’re fine with that. For that you’re using optional symbol ?:

type Subscription = {
  name: string;
  price: number;
  recurrence: "annual" | "monthly" | "weekly";
  endDate?: Date; // optional property
};

Combining existing types by using intersections

It’s possible to combine multiple existing types into a new one by using intersection & operator. The new type will have all of the properties of the used types.

type PrimaryAttrs = {
  name: string;
  price: number;
};

type RecurranceAttrs = {
  recurrence: "annual" | "monthly" | "weekly";
  endDate?: Date;
};

type Subscriptioon = PrimaryAttrs & RecurranceAttrs;

Intersections are applied recursively on all the properties. If there is a property present in both of the types it will also be intersected.

Records

Typescript provides utility type Record which you can define as following:

type Record<K, V> = {
  [key in K]: V;
};

It leverages JavaScript standard in operator. It returns true if the specified property key is in the specified object K.

Record can be used when you need to construct an object with entries of the same type:

type FeatureFlags = Record<string, boolean>;

Pick

Typically used when there is a need to construct a new type by picking a subset of properties:

type Subscription = {
  name: string;
  price: number;
  recurrence: "annual" | "monthly" | "weekly";
};

type Recurrence = Pick<Subscription, "recurrence">;
// instead of alternative
type RecurrenceAlt = {
  recurrence: "annual" | "monthly" | "weekly";
};

type PrimaryAttrs = Pick<Subscription, "name" | "price">;
// instead of alternative
type PrimaryAttrsAlt = {
  name: string;
  price: number;
};

Omit

Kind of opposite to Pick in the way it allows to construct a new type by omitting specified keys:

type Subscription = {
  name: string;
  price: number;
  recurrence: "annual" | "monthly" | "weekly";
};

type PrimaryAttrs = Omit<Subscription, "recurrence">;
// instead of alternative
type PrimaryAttrsAlt = {
  name: string;
  price: number;
};

type Recurrence = Omit<Props, "name" | "price">;
// instead of alternative
type RecurrenceAlt = {
  recurrence: "annual" | "monthly" | "weekly";
};

By using Pick and Omit we can create other util functions. Here is how you would emulate the behaviour of JavaScript assign that allows you to do { ...a, ...b } but on types:

type Assign<A, B> = Omit<A, keyof B> & B;
// it overrides properties of `A` with properties of `B`

const assign = <A, B>(obj1: A, obj2: B): Assign<A, B> => ({
  ...obj1,
  ...obj2,
});

It works because intersections are applied recursively.

Infer

infer is most often used in conditional types to find a type from another type. It allows you to put inferred type into variable and use it later. It can be used with conditional branches.

Here is an example of inferring return type from promise.

type GetPromiseReturnType<T> = T extends Promise<infer R> ? R : never;

type PromiseType = GetPromiseReturnType<Promise<number>>;
// type PromiseType = number

satisfies

Let’s consider the following annotation:

const subscriptions: Record<string, number> = {};

In this case Record<string, number> operates on the subscriptions variable. We can try to put the limitations on the value variable holds:

const subscriptions = {
  free: 0,
  basic: 75,
  premium: 120.5,
} satisfies Record<string, number>;

In such context with satisfies you can make sure that condition is met when applied to the value. With such notation you wouldn’t be able to assign new properties to subscriptions as it’s narrowing down the type. Most likely that’s not what you want.

unknown vs any

  • unknown type is safer than any. Typescript makes you check the type before you can use variable of type unknown.
    • Use it when you don’t know what type of variable it is but you still want to check the type before using it.
  • With any you can skip type checking. Variables of this type can be set to anything else and code wouldn’t fail to compile.
    • Use sparingly. It’s most often used during transition period to TypeScript, when you want to cut some corners to move faster.

References