TypeScript Type with Full Optional Field Might Cause you Issue
Posted on: 2022-03-30
I recently received a message from one of my teammates asking to look at a piece of code that was compiled but wrong. After a moment, I realized that the interface (type) that was used was coming from a generated package from one of our gRPC. Because all the gRPC Protobuf type had their fields optional, the generated types were also optional.
Example of the Issue
Here are two types that illustrate the situation.
interface TypeA {
id?: number;
name?: string;
}
interface TypeB {
id?: number;
age?: number;
}
The problem with TypeA
and TypeB
having all their fields optional is that a valid TypeA
can be {}
. And that a valid TypeB
can be {}
. By consequence, a function like the following accepts either type.
function forEach(a: TypeA): void {
console.log(a);
}
I did not choose the name forEach
by accident. The real forEach
or map
function suffers from the same behavior. If you expect a collection of <T>
and that your T
is a type of full optional field, then you can fall into the illusion that you are passing the right object when you are actually not. Before diving any further, you can play with the following code to see by yourself how the function forEach
that is expecting TypeA
accept a TypeB
and even {}
.
interface TypeA {
id?: number;
name?: string;
}
interface TypeB {
id?: number;
age?: number;
}
function forEach(a: TypeA): void {
console.log(a);
}
const a: TypeA = { id: 1, name: "Test" };
const b: TypeB = { id: 1, age: 10 };
forEach(a);
forEach(b);
forEach({});
Let's explain why with some examples.
Example 1: Implicit Declaration
Let's define a variable a
.
const a = { id: 1, name: "Test" }; // Coubt be TypeA
The variables a
is not explicitly typed. It does not have : TypeA
after the a
. It is not officially a TypeA
but a "on-the-fly" unamed type that share the same structure. Thus, is is totally acceptable to
const a: TypeA = { id: 1, name: "Test" };
Hence, the variable a
compiles when used in the forEach
function. It is important to understand that we are not coercing the type to be TypeA
. That would happen if we were doing:
const a = { id: 1, name: "Test" } as TypeA; // THIS IS BAD, DO NOT DO IT but it compiles
That should never be done since you can convert anything to TypeA
even if it is not true.
const x = {asd:123} as TypeA; // THIS IS WRONG but it compiles
Example 2: Explicit Declaration
The forEach
does not accept TypeB
. Defining a variable b
with the explicit type TypeB
and providing only the field that is common with TypeA
is not enough to have b
to compile when passing to forEach
.
const b: TypeB = { id: 1 };
forEach(b); // Does not work because we typed explicitly to TypeB (and having the right TypeB structure, hence no coearcing with as)
That makes sense in terms of "naming" being different. Hence, by being explicit, you are protecting yourself.
Example 3: Implicit or Explicit with Discriminant Field
While I try to be as explicit as possible, there are many situations where being implicit is valid.
There is an easy fix that is a default when using GraphQL. In GraphQL, when you generate your TypeScript type (interface), you receive all the translated properties from the GraphQL schema type to the TypeScript interface. However, it comes with a discriminant field. The discriminant field is a field in which the type is a unique string. It is critical to understand that the field type is not of string but a particular string. If we transform the previous example to have a discriminant field, the two types become:
interface TypeA {
__typename: "TypeA";
id?: number;
name?: string;
}
interface TypeB {
__typename: "TypeB";
id?: number;
age?: number;
}
With the field in place, you can write:
const d = { __typename: "TypeA" as const, id: 1 }; // Match TypeA without specifying the type
forEach(d); // Works because __typename is defined
The variable d
is not officially of type TypeA
but it does respect the structure of TypeA
and hence can be used with the forEach
.
interface TypeA {
__typename?: "TypeA";
id?: number;
name?: string;
}
interface TypeB {
__typename?: "TypeB";
id?: number;
age?: number;
}
Differently but with the same result, if you have the discriminant optional, it remains enough to block a type with a discriminant of a type that is not TypeA
.
const e = { __typename: "TypeB" as const, id: 1 };
forEach(e);
The reason is that the forEach
expect TypeA
or should we way a structure that respects:
__typename?: "TypeA";
id?: number;
name?: string;
We can see that because __typename
of e
is set to TypeB
that it does not pass the structural comparison. The accepted values are for __typename
to be TypeA
or nothing at all.
Hence this compile:
const e = { __typename: undefined, id: 1 };
const f = { __typename: "TypeA" as const, id: 1 };
const g = { id: 1, name: undefined };
forEach(e);
forEach(f);
forEach(g);
However, this one does not:
const h = { __typename: "TypeA" , id: 1 };
forEach(g);
A keen observer understands that the type h
is still not of TypeA
but results in an unnamed type of {__typename: string; id: number}
which is not the same as {__typename: "TypeA"; id: number}
. We are using as const
previously to have TypeScript narrowing down the type to is strictness definition.
Conclusion
Interfaces and types are structural in TypeScript, and the name does not matter. However, it is essential to understand the concept, and it is potent as it does not require a lot of work around building an object with a name in mind. Relying on a discriminant field is one of my favorite patterns. It does not require creating a function to create a user-defined guard removing to work with casting and generating a lot of code as the amount of interface grow. Also, it works well even if all the fields are optional, which might happen as we saw in the scenario that a situation force you to have all the fields optional (e.g., Protobuf outside your control).