This site runs best with JavaScript enabled.

How to write a Constrained Identity Function (CIF) in TypeScript

Software Engineer, React Training, Testing JavaScript Training

Photo by Sabri Tuzcu


A handy advanced TypeScript pattern to increase your productivity.

In How to write a React Component in TypeScript, I typed an example React component. Here's where we left off:

1const operations = {
2 '+': (left: number, right: number): number => left + right,
3 '-': (left: number, right: number): number => left - right,
4 '*': (left: number, right: number): number => left * right,
5 '/': (left: number, right: number): number => left / right,
6}
7
8type CalculatorProps = {
9 left: number
10 operator: keyof typeof operations
11 right: number
12}
13
14function Calculator({left, operator, right}: CalculatorProps) {
15 const result = operations[operator](left, right)
16 return (
17 <div>
18 <code>
19 {left} {operator} {right} = <output>{result}</output>
20 </code>
21 </div>
22 )
23}
24
25const examples = (
26 <>
27 <Calculator left={1} operator="+" right={2} />
28 <Calculator left={1} operator="-" right={2} />
29 <Calculator left={1} operator="*" right={2} />
30 <Calculator left={1} operator="/" right={2} />
31 </>
32)

I'm not satisfied with the operations function though. I know that every function in that object is going to have the exact same type (by necessity due to the use case):

1type OperationFn = (left: number, right: number) => number

The operations object is really just a record of operation strings mapped to a function that operates on two numbers. So if we add a type annotation on our operations variable, then we don't have to type each function individually. Let's try that:

1type OperationFn = (left: number, right: number) => number
2const operations: Record<string, OperationFn> = {
3 '+': (left, right) => left + right,
4 '-': (left, right) => left - right,
5 '*': (left, right) => left * right,
6 '/': (left, right) => left / right,
7}
8
9type CalculatorProps = {
10 left: number
11 operator: keyof typeof operations
12 right: number
13}

Sweet, so we don't have to type every function individually, but oh no... now the typeof operations is Record<string, OperationFn> and the keyof of that is going to be string which means our CalculatorProps['operator'] type will be string. Ugh 😩

Here's what we could do to fix this:

1type OperationFn = (left: number, right: number) => number
2type Operator = '+' | '-' | '/' | '*'
3const operations: Record<Operator, OperationFn> = {
4 '+': (left, right) => left + right,
5 '-': (left, right) => left - right,
6 '*': (left, right) => left * right,
7 '/': (left, right) => left / right,
8}
9
10type CalculatorProps = {
11 left: number
12 operator: keyof typeof operations
13 right: number
14}

But now we're back to having to add ** in two places if we decide to add the Exponentiation operator. However, in this case, TypeScript will give us a compiler error if we add it in one and not the other, so that's a step up.

This is where I left this when I first wrote this component, but then @AlekseyL13 suggested that I try a properly typed identity function.

The constrained identity function

First, let's keep in mind, we have 2 goals:

  1. Enforce that the type of each property is the same (in this simple example, it's just a number, but in our actual example, it's a function type)
  2. Ensure that keyof typeof for our object results in a finite union of the keys

With TypeScript, it's a challenge to have both of these. By default, we get the second goal. The problem is that when you try to accomplish the first goal with a type annotation like const operations: Record<string, OperationFn> = ..., you end up widening the key so keyof typeof results in string. Ugh, how annoying.

So here's where the constrained identity function comes in. By the way, "constrained" describes a situation where you have a function that accepts a narrower version of an input than it's passed.Here's a simple example:

1type NamedObject = {name: string}
2function getUserName<User extends NamedObject>(user: User) {
3 return user.name
4}
5
6const obj = {name: 'Hannah', age: 3}
7getUserName(obj)

So the object that's passed to getUserName must satisfy all the types in the NamedObject. The getUserName constrains the input to at least match that type.

And an "identity function" is a function that accepts a value and returns that value. I sometimes use these kinds of functions as the default value for callbacks:

1const identity = <Type extends unknown>(item: Type) => item
2
3type ModifyConfigFn = (config: ConfigType) => ConfigType
4function buildProject(modifyConfig: ModifyConfigFn = identity) {
5 const config: ConfigType = {
6 /* some config */
7 }
8 const modifiedConfig = modifyConfig(config)
9 // more stuff...
10}

So with those definitions out of the way, a "constrained identity function" is a function which returns what it is given and also helps TypeScript constrain its type. This is exactly what we want to do.

We can call it a CIF (pronounced "see eye eff"). Sure, let's go with that.

Let me show you a simple example first, then I'll explain what's going on, then we can apply it more usefully to our more complicated example:

1type Value = number
2const createNumbers = <ObjectType extends Record<string, Value>>(
3 obj: ObjectType,
4) => obj
5
6const numbers = createNumbers({one: 1, two: 2, three: 3, four: 4})
7
8// @ts-expect-error we don't have 'five' yet
9numbers['five']

So the createNumbers is the constrained identity function. It returns the obj it's given, hopefully that's clear. But how does it enforce our input and constrain the type?

Let me explain it this way. If we start with:

1const numbers = {one: 1, two: 2, three: 3, four: 4}
2// typeof numbers:
3// {
4// one: number;
5// two: number;
6// three: number;
7// four: number;
8// }

But in the future, someone could come to this code and change it like this:

1const numbers = {one: 1, two: 2, three: 3, four: 4, five: '5'}
2// typeof numbers:
3// {
4// one: number;
5// two: number;
6// three: number;
7// four: number;
8// five: string; // 😱
9// }

Yikes! Nah, we can't have that! (And, more importantly, in our Calculator example, some auto-typing on the functions is the goal here).

So, let's enforce our value types with a type annotation:

1// @ts-expect-error HA! We gotcha! No strings in this object!
2const numbers: Record<string, number> = {
3 one: 1,
4 two: 2,
5 three: 3,
6 four: 4,
7 five: '5',
8}

But now by typing our values explicitly, we've told TypeScript that our key can be a string. Unfortunately, there's no way to tell TypeScript: "This thing has the keys it has, but the values are this specific type." IMO, this is a missing feature of TypeScript. Our createNumbers constrained identity function (er... "CIF") is a workaround.

So here's what that workaround is:

Constrained identity functions allow us to not explicitly annotate our variable while still getting to enforce the values.

So we create the object, get the best type that TypeScript can offer us (which includes the narrow keys and wide values), then we pass it to a function which accepts wide keys and narrow values. TypeScript combines that to give us a Record with a key and value which are both narrow!

Alrighty, so let's apply a CIF to our original situation:

1type OperationFn = (left: number, right: number) => number
2const createOperations = <OperationsType extends Record<string, OperationFn>>(
3 operations: OperationsType,
4) => operations
5
6const operations = createOperations({
7 '+': (left, right) => left + right,
8 '-': (left, right) => left - right,
9 '*': (left, right) => left * right,
10 '/': (left, right) => left / right,
11})
12
13type CalculatorProps = {
14 left: number
15 operator: keyof typeof operations
16 right: number
17}
18
19// @ts-expect-error we haven't added support
20// for the exponentiation operator yet
21operations['**'](1, 2)

Wahoo! So with this solution we don't have to explicitly type all the operation functions the exact same way and we can still get a union type of all available operations.

A generic CIF?

You may have noticed that we had two CIFs in the previous section:

1type Value = number
2const createNumbers = <ObjectType extends Record<string, Value>>(
3 obj: ObjectType,
4) => obj
5
6type OperationFn = (left: number, right: number) => number
7const createOperations = <OperationsType extends Record<string, OperationFn>>(
8 operations: OperationsType,
9) => operations

Wouldn't it be neat if we could combine those? Sure would. But you're not going to like it... Here's what I tried first:

1const constrain = <Given, Inferred extends Given>(item: Inferred) => item
2
3// @ts-expect-error Expected 2 type arguments, but got 1.(2558)
4const numbers = constrain<Record<string, number>>({one: 1 /* etc. */})

Sad day. Unfortunately this is just not possible with TypeScript today. But here's a workaround:

1const constrain = <Given extends unknown>() => <Inferred extends Given>(
2 item: Inferred,
3) => item
4
5const numbers = constrain<Record<string, number>>()({one: 1 /* etc. */})

... yeah, I told you you wouldn't like it. It's marginally better like this:

1const createNumbers = constrain<Record<string, number>>()
2const numbers = createNumbers({one: 1 /* etc. */})

But like, huh. Bummer.

Luckily, I don't find myself making CIFs very often anyway and they aren't difficult to write so I don't need an abstraction for them. Thought it'd be interesting to share with you though 😄

Conclusion

Here's the final version of our calculator component with everything typed with our CIF:

1type OperationFn = (left: number, right: number) => number
2const createOperations = <OperationsType extends Record<string, OperationFn>>(
3 opts: OperationsType,
4) => opts
5
6const operations = createOperations({
7 '+': (left, right) => left + right,
8 '-': (left, right) => left - right,
9 '*': (left, right) => left * right,
10 '/': (left, right) => left / right,
11})
12
13type CalculatorProps = {
14 left: number
15 operator: keyof typeof operations
16 right: number
17}
18function Calculator({left, operator, right}: CalculatorProps) {
19 const result = operations[operator](left, right)
20 return (
21 <div>
22 <code>
23 {left} {operator} {right} = <output>{result}</output>
24 </code>
25 </div>
26 )
27}
28
29const examples = (
30 <>
31 <Calculator left={1} operator="+" right={2} />
32 <Calculator left={1} operator="-" right={2} />
33 <Calculator left={1} operator="*" right={2} />
34 <Calculator left={1} operator="/" right={2} />
35 </>
36)

Yup, this entire blog post was written just to explain those 3 lines of code to you. So yeah, there you go.

Some folks may finish reading this post and scoff, saying things like: "Why would you ever want to use TypeScript if it requires you to do weird things like this?"

First, I'd say that just because a tool like TypeScript requires workarounds for some stuff like this doesn't mean it's not worthwhile. The cost here is minimal and the benefit is significant. I'm not here to convince you to use TypeScript. I can't do as good a job convincing you as your runtime bugs do I'm sure 😜 Secondly, this is definitely something that could improve with TypeScript in the future. In fact, this may be a nice step to improving things. Finally, like I said, this isn't something that we're doing all the time. Most of my time with TypeScript is delightful.

Take care!

Discuss on TwitterEdit post on GitHub

Share article
EpicReact.Dev

Get Really Good at React

Blast Off

Write professional React.

TestingJavaScript.com

Your Essential Guide to Flawless Testing

Start Now

Write well tested JavaScript.

Kent C. Dodds

Kent C. Dodds is a JavaScript software engineer and teacher. He's taught hundreds of thousands of people how to make the world a better place with quality software development tools and practices. He lives with his wife and four kids in Utah.

Join the Newsletter



Kent C. Dodds