Dto
(Data Transfer Object) is a TypeScript utility type that can be used to represent an object that is going "over the wire". It's meant to be used at the boundaries of an application. You can find the code at https://github.com/tamj0rd2/dto and at the bottom of this post.
What problem does it try to solve?
In Javascript, sending objects via network requests is generally pretty easy, but a problem arises when making use of classes. Take the following example using a Date
Here's some client side code that tries to create a Post via the endpoint /api/posts
:
interface Post {
author: string
title: string
datePublished: Date
}
const post: Post = {
author: 'tamj0rd2',
title: 'Something about TypeScript',
datePublished: new Date()
}
await fetch('/api/posts', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(post)
})
Let's take a look at how we're handling this request on the server side:
import express from 'express'
const app = express()
app.use(express.json()) // parses our request body into JSON
app.post<unknown, unknown, Post>('/api/posts', (req, res) => {
// if you were to hover your mouse over datePublished below, your IDE would say its type is Date
console.log(`The type of datePublished is '${typeof req.body.datePublished}'`)
res.writeHead(201).end()
})
app.use((_, res) => res.sendStatus(404))
app.listen(8080, () => console.log('server listening on http://localhost:8080'))
If you thought the output of that console log line might say the type of datePublished is object
, you'd be wrong. The actual type is string
. Remember, the post was stringified on the client side and then parsed back into an object on the server side. In Javascript, the stringified result of a Date
is a string
and the parsed result of a string
is... a string
.
Date
isn't the only class whose stringified structure is different to what you might expect. Set
and Map
don't stringify in a way you'd expect at all.
JSON.stringify(new Set([1, 2, 3])) // outputs "{}" but what I actually want is "[1, 2, 3]"
const myMap = new Map<string, string>()
myMap.set('Hello', 'World')
JSON.stringify(myMap) // outputs "{}" but what I actually want is "{"Hello":"World"}"
So although we've told our request handler that the request body should be of type Post
, it isn't actually true. This would be more accurate:
interface PostDto {
author: string
title: string
datePublished: string
}
app.post<unknown, unknown, PostDto>('/api/posts', (req, res) => {
console.log(`The type of datePublished is '${typeof req.body.datePublished}'`)
res.writeHead(201).end()
})
Why do I care about the accuracy of these types? They've helped to catch bugs in multiple codebases I've contributed to. One common case is trying to call functions on something you think is a Date but is actually a string. I like these types to be accurate for the same reason that I like TypeScript. It helps me catch bugs sooner rather than later.
But I don't want to write and maintain two different versions of an interface >:(
Well that's what the Dto
utility type attempts to solve! The examples I'm showing in this post are so small that you might be wondering why I'm bothered by it. I've faced the pain of rewriting interfaces with dozens of properties and/or nested objects. It's time consuming and I don't like doing it. Once you have two interfaces you also have to maintain them and keep them in sync whenever you add, remove or change the types of properties.
What the Dto
utility type doesn't do is serialize or validate your data. It's just a type
. It can't do serialization or validation for you because it doesn't exist at runtime. You're still responsible for getting your code from it's "initial" type to it's "Dto-ified" type.
Example usage
app.post<unknown, unknown, Dto<Post>>('/api/posts', (req, res) => {
res.writeHead(201).end()
})
In this example, instead of having to write out another interface for our "over the wire" version of a Post, we're just using the Dto
utility type. Dto<Post>
is equivalent to the PostDto
interface we wrote out earlier.
Show me the money
Here it is! Although the most up to date version can always be found here
type IsOptional<T> = Extract<T, undefined> extends never ? false : true
export type Func = (...args: any[]) => any
type IsFunction<T> = T extends Func ? true : false
type IsValueType<T> = T extends
| string
| number
| boolean
| null
| undefined
| Func
| Set<any>
| Map<any, any>
| Date
| Array<any>
? true
: false
type ReplaceDate<T> = T extends Date ? string : T
type ReplaceSet<T> = T extends Set<infer X> ? X[] : T
type ReplaceMap<T> = T extends Map<infer K, infer I>
? Record<
K extends string | number | symbol ? K : string,
IsValueType<I> extends true ? I : { [K in keyof ExcludeFuncsFromObj<I>]: Dto<I[K]> }
>
: T
type ReplaceArray<T> = T extends Array<infer X> ? Dto<X>[] : T
type ExcludeFuncsFromObj<T> = Pick<T, { [K in keyof T]: IsFunction<T[K]> extends true ? never : K }[keyof T]>
type Dtoified<T> = IsValueType<T> extends true
? ReplaceDate<ReplaceMap<ReplaceSet<ReplaceArray<T>>>>
: { [K in keyof ExcludeFuncsFromObj<T>]: Dto<T[K]> }
export type Dto<T> = IsFunction<T> extends true
? never
: IsOptional<T> extends true
? Dtoified<Exclude<T, undefined>> | null
: Dtoified<T>
export type Serializable<T> = T & { serialize(): Dto<T> }
Since I wrote this I've learned more about Typescript and there is definitely some room for improvement, but hopefully someone else will get some use out of this. I'm planning to follow this up with another post on how I wrote the utility type and how I made sure I didn't break it as I added more functionality. Types are fiddly!
In the meantime, you can learn more about a lot of the tools that went into creating this type using the TypeScript utility types documentation and this blog post on Conditional types. I can't recommend that post enough.
Top comments (2)
You should make this a npm package, it's very helpfull.
hit me hard