Typing Unknown Objects in TypeScript With Record Types

Background
When I was asked to review an unfamiliar codebase and make some changes to it in accordance with changing requirements, I came across some code that was not taking full advantage of TypeScript. At best, it was surface level usage of TypeScript without the benefits. In particular, it was not using TypeScript features for strongly typed object access. I wrote this article mainly as a way for me to share the concept of record types with other developers.
For reference, this is how an object could be created in JavaScript:
const foo = { bar: true, baz: false };
The code is obviously correct TypeScript too. But you may wish to declare a type first when using TypeScript. Note that type
or interface
can be used for this example - it’s probably worth reading this page of the documentation if you’re not familiar with type
versus interface
.
type Foo = { bar: boolean, baz: boolean };
const foo: Foo = { bar: true, baz: false };
But what happens when you, the developer, do not know the properties ahead of time? How do you write code that lets TypeScript know you’re expecting an object with properties but you don’t yet know what the names of those properties are going to be? This is a fairly common scenario when designing libraries and frameworks that defer naming to the developer using the library.
Here’s a hint. The solution is not to fallback to simply throwing Object
, any
, or {}
at the problem thus losing all type safety.
Introducing the Record type
The problem code I encountered was written in a similar fashion to the following code. I’ve made some minor changes for clarity.
export interface FeatureSwitchItem {
name: string;
isOn?: boolean;
}
export const Features = {
general: { name: 'Lorem.Ipsum.General' } as FeatureSwitchItem,
settings: { name: 'Dolor.Sit.Settings' } as FeatureSwitchItem,
user: { name: 'Amet.Elit.User' } as FeatureSwitchItem,
time: { name: 'Etiam.Neque.Time' } as FeatureSwitchItem,
};
There are a some problems with this code. The first being the obnoxious amount of casts and the second being the lack of type declared for the Features
object itself. I’ll focus on the casts first and then explain why they are not needed here and, in this instance, give a false sense of security.
The code still compiles if the casts are removed. This is an indicator these casts are useless and don’t provide any immediate benefit. It’s likely that when new properties are added to the object the cast is forgotten and thus the following code is valid when it definitely shouldn’t be. Needing to manually specify the type every time is a code smell.
{ name: 'Lorem.Ipsum.General', isOn: 'apples' },
It should probably never be updated from within the application. The exported Features
object was available throughout the entire application and is open to changes from anywhere that imports it. This is a really poor practice for such configuration. By using either the readonly
keyword on each property or the Readonly<T>
utility type on the object itself or via an as const
assertion we can inform TypeScript that this is not intended to change. Attempting to change it will result in an error. As an advocate for functional programming, I like this a lot.
A specific type for the object would remove the “need” for the casts. We can let TypeScript know the type beforehand. Not only do we get type safety but we’ll get intellisense/autocomplete support too. If we typed the cast after the object expression, we’d have missed out on this feature.
Known properties
The first solution for properties you know ahead of time would be to specify every property in the type. There’s nothing special about this and the code should not be surprising. This is ideal for situations in which a set of properties are known and therefore not flexible - which is fine for most situations.
type Configuration = {
readonly connectionString: string;
readonly timeoutInMilliseconds: number;
};
Unknown properties
So, how do we approach being able to specify what type a property should be, without also knowing the name of that property? Record types! Here is the refactored version of the original code.
interface FeatureSwitchItem {
readonly name: string;
readonly enabled: boolean;
}
type FeatureToggles = Readonly<Record<string, FeatureSwitchItem>>;
const features: FeatureToggles = Object.freeze({
general: { name: 'Lorem.Ipsum.General', enabled: false },
settings: { name: 'Dolor.Sit.Settings', enabled: true },
user: { name: 'Amet.Elit.User', enabled: false },
time: { name: 'Etiam.Neque.Time', enabled: true },
});
Try update the properties in the TypeScript playground and note the error messages.
Conclusion
- I’ve introduced a type for the feature switches that allows for properties of any name
- Ensured that no redundant casts are required
- Renamed the
isOn
property to what I consider a clearer name - I do likeis
andhas
prefixes but for this property “is on” sounded borderline tautological - The new
enabled
property is no longer optional - optional booleans are confusing at the best of times as some software treats false and missing values differently while others do not and I felt that for cross cutting concerns like configuration and feature switches any cognitive load should be reduced - Avoided any horrible
Object
,{}
,any
nonsense - Enforced readonly/immutable semantics both at build time and runtime
- If you trust the consumers of the code to not try break the design at runtime then the
Object.freeze()
is not necessary
- If you trust the consumers of the code to not try break the design at runtime then the
If you liked this TypeScript article and would like to read more, I highly recommend you read another article I have on some more advanced TypeScript concepts! It’s called Going Further With TypeScript - Part 1: Mapped Types.