DEV Community

Tamara Jordan
Tamara Jordan

Posted on

DTO - A typescript utility type

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)
})
Enter fullscreen mode Exit fullscreen mode

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'))
Enter fullscreen mode Exit fullscreen mode

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"}"
Enter fullscreen mode Exit fullscreen mode

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()
})
Enter fullscreen mode Exit fullscreen mode

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()
})
Enter fullscreen mode Exit fullscreen mode

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> }
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
ketrab2004 profile image
B. O.

You should make this a npm package, it's very helpfull.

Collapse
 
lapp1stan profile image
Stanislav Kniazev
type IsValueType<T> = T extends
  | string
  | number
  | boolean
  | null
  | undefined
  | Func
  | Set<any>
  | Map<any, any>
  | Date
  | Array<any>
  ? true
  : false
Enter fullscreen mode Exit fullscreen mode

hit me hard