I'm writing the implementation of an insert post
use case. I'm studying the Clean Architecture and I want to create an blog API, where you can create, read, update and delete posts.
The Clean Architecture principles says you have to isolate your application into layers, where each layer is responsible to abstract your code as much as possible. I divided my code into three main layers: core
(the most "immutable" abstractions, where I define the data models and the use cases), app
(where I wrote the main application code, like validations and other stuff) and resources
(the place for the less abstract code, like database connection, adapters, etc).
The general data was abstracted into the core
layer, since it's the most fundamental information of my API. But the general data (most specifically id
, created_at
, updated_at
, is_deleted
fields) was abstracted into the app
layer, because it's database dependent - the id
field can be both a number (for general SQL
databases) or a string (for MongoDB
, for example). Then, i just join the two abstractions into one single model to create the "definitive" application models.
But there's a problem with this: the use cases abstractions uses the id
data to define its implementation rules. By example, you have the InsertPost
use case, where the return data is the id
of the new created post. Since the id
is only defined at the app
layer, and Clean Architecture don't allow you to request data from more external layers, how can this abstraction know what type of id
he's returning? The solution I found is to abstract the id
type into the two possible types (number
or string
), and only provide the definitive type at the app
layer.
This is the described InsertPost
use case:
// ----- IMPORTS -----
type Id = number | string;
interface Post<IdType extends Id = Id> {
title: string
description: string
body: string
author_id: IdType
}
interface SuccessfulResponse<T> {
results: Array<T>
}
// -------------------
export namespace InsertPost {
export type Params<IdParam extends Id = Id> = Post<IdParam>
}
export interface InsertPost<IdType extends Id> {
insert: (params: InsertPost.Params) => Promise<SuccessfulResponse<IdType>>
}
I wrote the Params
type from the InsertPost
namespace to have a Generic Type that only accepts types derived from the Id
type. The IdType
should also have an default type when no type is given. My problem is that TypeScript is using the default Id
type no matter if a valid alternative type was provided or not.
This is my implementation of the InsertPost
use case:
// ----- IMPORTS -----
type Id = number | string;
interface Post<IdType extends Id = Id> {
title: string
description: string
body: string
author_id: IdType
}
interface SuccessfulResponse<T> {
results: Array<T>
}
namespace DefaultData {
export type Id = number
export type IsDeleted = boolean
export type CreatedAt = Date
export type UpdatedAt = Date
}
interface DefaultData {
id: DefaultData.Id
is_deleted: DefaultData.IsDeleted
created_at: DefaultData.CreatedAt
updated_at: DefaultData.UpdatedAt
}
interface InsertPostRepository {
insertPost: (user: Post) => Promise<DefaultData.Id>
}
function successfulResponseDataFormatter<T>(data: Array<T> | T): SuccessfulResponse<T> {
return {
results: Array.isArray(data) ? data : [data]
};
}
namespace InsertPost {
export type Params<IdParam extends Id = Id> = Post<IdParam>
}
interface InsertPost<IdType extends Id> {
insert: (params: InsertPost.Params) => Promise<SuccessfulResponse<IdType>>
}
// -------------------
export class InsertPostService implements InsertPost<DefaultData.Id> {
constructor(private readonly insertPostRepository: InsertPostRepository) {}
async insert(params: InsertPost.Params<DefaultData.Id>): Promise<SuccessfulResponse<DefaultData.Id>> { // TypeScript throws an error at this line
const { title, description, body, author_id } = params;
// Validation functions here
const id = await this.insertPostRepository.insertPost({
title,
description,
body,
author_id
});
return successfulResponseDataFormatter(id);
}
}
TypeScript throws the following errors:
Property 'insert' in type 'InsertPostService' is not assignable to the same property in base type 'InsertPost'.
Type '(params: Params) => Promise<SuccessfulResponse>' is not assignable to type '(params: Params) => Promise<SuccessfulResponse>'.
Types of parameters 'params' and 'params' are incompatible.
Type 'Params' is not assignable to type 'Params'.
Type 'Id' is not assignable to type 'number'.
Type 'string' is not assignable to type 'number'.
From what I've tested, this only happens when I export the type from inside a namespace
(unfortunately, I need to use it). I really can't see where I'm going wrong; is there something I'm missing to code, or is this a TypeScript bug?
CodePudding user response:
As written in your example, an InsertPost<XXX>
needs to have an insert
method whose argument is of type InsertPost.Params
, which evaluates to Post<Id>
; note how this is independent of XXX
. But your InsertPostService
has an insert
method whose argument is of type InsertPost.Params<DefaultData.Id>
, which evaluates to Post<DefaultData.Id>
. This is not the same type as Post<Id>
, and so you get a compiler error. It's possible that there are some technical issues surrounding parameter bivariance but that's the basic problem you're having.
Presumably you want InsertPost<XXX>
to have an insert
method whose argument depends on XXX
. The most plausible candidate I can think of given your other code is that it should be of type InsertPost.Params<XXX>
:
interface InsertPost<IdType extends Id> {
insert: (params: InsertPost.Params<IdType>) => Promise<SuccessfulResponse<IdType>>
// -----------------------------> ^^^^^^^^ <--- added this
}
Once I do that your code starts to compile, since your InsertPostService
implements InsertPost<DefaultData.Id>
. It's always possible you could run into other issues down the line, but this is at least a reasonable first step.