Skip to content

Instantly share code, notes, and snippets.

@airportyh
Last active March 3, 2018 08:19
Show Gist options
  • Select an option

  • Save airportyh/b1255f334d7f38ad332b3aadebeed8eb to your computer and use it in GitHub Desktop.

Select an option

Save airportyh/b1255f334d7f38ad332b3aadebeed8eb to your computer and use it in GitHub Desktop.

Optionally Nullable

We are incrementally upgrading a JS app to TS. We are using the strict null check option, which we deem important to helping us deliver quality code. During the upgrade process, we encountered a scenario where for a given object with a fixed set of properties, although we'd like to assume the values of the properties are there in the main path of the program, we'd like for them to be null for a couple of special cases:

  1. When we are creating a new version of the object, and all of its values --- even the required ones, are as of yet unknown.
  2. When updating an object, we typically only send the changes that were made to the object, rather than all its values. This necessarily generates a object where some of the required properties are missing.

The typical approach to this is to make every property in the interface describing the object optional --- but that has the disadvantage of requiring null checks at many places, and we'd like to avoid that.

While it is possible to re-design our app in a way that avoids these issues, I felt that was too much to ask of the team --- to put in extra effort to redesign / refactor simply to upgrade to TypeScript. TS has a powerful type system, there must be a way...

And I came up with one --- for which I'd like to get some feedback from the community.

So let's say you have an object interface:

interface IUser {
  firstName: string;
  lastName: string;
  email: string;
  birthDate: Date;
}

If you try to create a variable of this type and assign it to an object, it won't allow or null values:

let myUser2: IUser = {
  firstName: null,
  lastName: null,
  email: null,
  birthDate: null
};

You get this error:

[ts]
Type '{ firstName: null; lastName: null; email: null; birthDate: null; }' is not assignable to type 'IUser'.
  Types of property 'firstName' are incompatible.
    Type 'null' is not assignable to type 'string'.

Scheme: Optionally Nullable

What if we introduced a type parameter NN into this interface's definition like so --- where NN can be either never or null?

interface IUser<NN> {
  firstName: string | NN;
  lastName: string | NN;
  email: string | NN;
  birthDate: Date | NN;
}

let strictlyUser: IUser<never> = {
  firstName: null,
  lastName: null,
  email: null,
  birthDate: null
};
let nullableUser: IUser<null> = {
  firstName: null,
  lastName: null,
  email: null,
  birthDate: null
}

Now, the statement assigning an object with null values to strictlyUser will still cause an error, but the statement for nullableUser will not. We can further refine this so that the default behavior is to require all values by defaulting NN's type to never:

interface IUser<NN = never> {
  firstName: string | NN;
  lastName: string | NN;
  email: string | NN;
  birthDate: Date | NN;
}

So that you can use the type IUser without having to provide the type parameter:

let strictlyUser: IUser = {
  firstName: "Anushka",
  lastName: "Laghari",
  email: "anushka@aol.com",
  birthDate: new Date("2010-03-14")
};

And nullability is something you can optionally turn on by supplying null as the type parameter:

let nullableUser: IUser<null> = {
  firstName: null,
  lastName: null,
  email: null,
  birthDate: null
}

Scaling Up

In our code base, we have a base interface which the other model interfaces extend from. To make this scheme handle interface inheritance, you can do the following (Identifiable is the base interface in this example):

interface Identifiable<NN = never> {
  id: number | NN;
}

interface IUser<NN = never> extends Identifiable<NN> {
  firstName: string | NN;
  lastName: string | NN;
  email: string | NN;
  birthDate: Date | NN;
}

If you want to define an interface that extends from Identifiable but doesn't need optional nullability, you simply extend it without supplying a type parameter --- because it is optional and defaults to never:

interface ICompany extends Identifiable {
	name: string;
	address: string;
	website: string;
}

Fine-Grained Nullability

If you have an object where sometimes you want one set of fields to be nullable, but other times you want another set of fields to be nullable, you can do this:

interface IProject<BasicNN = never, DetailsNN = never> extends Identifiable<BasicNN> {
	name: string | BasicNN;
	website: string | BasicNN;
	description: string | DetailsNN;
	teamLead: IUser | DetailsNN;
	personnel: IUser[] | DetailsNN;
}

let fullProject: IProject;
let nullableDetailsProject: IProject<never, null>;
let nullableBasicsProject: IProject<null, never>;
let allNullableProject: IProject<null, null>;

We have 2 type parameters: BasicNN and DetailsNN. Both of them default to never, which means that if you don't specify either of them, all properties are non-nullable --- such is the variable fullProject.

nullableDetailsProject has its BasicNN parameter set to never and DetailsNN set to null, which means each of its detail properties: description, teamLead, and personnel, will be nullable.

nullableBasicsProject is the reverse of nullableDetailsProject. Its basic properties: name, website, and also id (inherited from Identifiable) are nullable, while the detail properties are not.

allNullableProject has all its fields nullable.

Feedback

If you have opinions on this approach, I'd love to hear it! Are there things I should be worried about with this approach? Is this something you'd use? Please comment below.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment