Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat/type-safe: Encourage type-safe coding practices with eslint-plugin-typesafe #943

Merged
merged 11 commits into from
Jan 5, 2021
5 changes: 3 additions & 2 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
},
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint", "import", "simple-import-sort"],
"plugins": ["@typescript-eslint", "import", "simple-import-sort", "typesafe"],
"extends": ["plugin:@typescript-eslint/recommended"],
"rules": {
// Rules for auto sort of imports
Expand Down Expand Up @@ -54,7 +54,8 @@
"import/newline-after-import": "error",
"import/no-duplicates": "error",
"@typescript-eslint/no-floating-promises": 2,
"@typescript-eslint/no-unused-vars": 2
"@typescript-eslint/no-unused-vars": 2,
"typesafe/no-throw-sync-func": "error"
}
},
{ "files": ["*.spec.ts"], "extends": ["plugin:jest/recommended"] }
Expand Down
423 changes: 423 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@
"eslint-plugin-jest": "^24.1.3",
"eslint-plugin-prettier": "^3.3.0",
"eslint-plugin-simple-import-sort": "^6.0.1",
"eslint-plugin-typesafe": "^0.2.0",
"form-data": "^3.0.0",
"google-fonts-plugin": "4.1.0",
"html-loader": "~0.5.5",
Expand Down
4 changes: 2 additions & 2 deletions src/app/models/form_statistics_total.server.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ const compileFormStatisticsTotalModel = (db: Mongoose) => {
// Hooks
FormStatisticsTotalSchema.pre<IFormStatisticsTotalSchema>(
'save',
function () {
throw new Error('FormStatisticsTotal schema is read only')
function (next) {
next(new Error('FormStatisticsTotal schema is read only'))
},
)

Expand Down
3 changes: 0 additions & 3 deletions src/app/modules/form/admin-form/admin-form.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { err, ok, Result } from 'neverthrow'

import { createLoggerWithLabel } from '../../../../config/logger'
import { IPopulatedForm, ResponseMode, Status } from '../../../../types'
import { assertUnreachable } from '../../../utils/assert-unreachable'
import {
ApplicationError,
DatabaseConflictError,
Expand Down Expand Up @@ -205,8 +204,6 @@ export const getAssertPermissionFn = (level: PermissionLevel): AssertFormFn => {
return assertHasWritePermissions
case PermissionLevel.Delete:
return assertHasDeletePermissions
default:
return assertUnreachable(level)
}
}

Expand Down
3 changes: 0 additions & 3 deletions src/app/modules/form/form.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow'
import { createLoggerWithLabel } from '../../../config/logger'
import { IFormSchema, IPopulatedForm, Status } from '../../../types'
import getFormModel from '../../models/form.server.model'
import { assertUnreachable } from '../../utils/assert-unreachable'
import { getMongoErrorMessage } from '../../utils/handle-mongo-error'
import { ApplicationError, DatabaseError } from '../core/core.errors'

Expand Down Expand Up @@ -116,7 +115,5 @@ export const isFormPublic = (
return err(new FormDeletedError())
case Status.Private:
return err(new PrivateFormError(form.inactiveMessage, form.title))
default:
return assertUnreachable(form.status)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,5 @@ describe('submission.utils', () => {
)
expect(actual.length).toEqual(2)
})

it('should throw error if called with invalid responseMode', async () => {
// Arrange
const invalidResponseMode = 'something' as ResponseMode
// Act + Assert
expect(() => getModeFilter(invalidResponseMode)).toThrowError(
`This should never be reached in TypeScript: "${invalidResponseMode}"`,
)
})
})
})
3 changes: 0 additions & 3 deletions src/app/modules/submission/submission.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { keyBy } from 'lodash'
import { BasicField, IFieldSchema, ResponseMode } from '../../../types'
import { isEmailField } from '../../../types/field/utils/guards'
import { AutoReplyMailData } from '../../services/mail/mail.types'
import { assertUnreachable } from '../../utils/assert-unreachable'
import { FIELDS_TO_REJECT } from '../../utils/field-validation/config'

import { ProcessedFieldResponse } from './submission.types'
Expand All @@ -20,8 +19,6 @@ export const getModeFilter = (
return emailModeFilter
case ResponseMode.Encrypt:
return encryptModeFilter
default:
return assertUnreachable(responseMode)
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/app/modules/verification/verification.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,5 +332,7 @@ const getFieldFromTransaction = (
const throwError = (message: string, name?: string): never => {
const error = new Error(message)
error.name = name || message
// TODO(#941) Convert this service to use neverthrow, and re-examine type assertions made
// eslint-disable-next-line
throw error
}
2 changes: 2 additions & 0 deletions src/app/services/sms/sms.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ export const createSmsFactory = (
const errorMessage = 'SMS feature must be enabled in Feature Manager first'
return {
sendAdminContactOtp: () => {
//eslint-disable-next-line
throw new Error(`sendAdminContactOtp: ${errorMessage}`)
liangyuanruo marked this conversation as resolved.
Show resolved Hide resolved
},
sendVerificationOtp: () => {
//eslint-disable-next-line
throw new Error(`sendVerificationOtp: ${errorMessage}`)
},
}
Expand Down
10 changes: 0 additions & 10 deletions src/app/utils/assert-unreachable.ts

This file was deleted.

5 changes: 5 additions & 0 deletions src/config/feature-manager/util/FeatureManager.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ const logger = createLoggerWithLabel(module)
convict.addFormat(validator.url)

export default class FeatureManager {
/* eslint-disable typesafe/no-throw-sync-func
--------
This class bootstraps the application and should
cause a crash in case of errors. */
public states: Partial<Record<FeatureNames, boolean>>
// Map some feature names to some env vars
private properties: Partial<IFeatureManager>
Expand Down Expand Up @@ -115,4 +119,5 @@ export default class FeatureManager {
props: this.props<K>(name),
}
}
/* eslint-enable typesafe/no-throw-sync-func */
}
3 changes: 3 additions & 0 deletions src/config/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ const createCustomLogger = (logger: Logger) => {
const { message, meta } = params
// Not the expected shape, throw to catch block.
if (!message || isEmpty(meta)) {
//eslint-disable-next-line
throw new Error('Wrong shape')
}
return logger.info(message, { meta })
Expand All @@ -209,6 +210,7 @@ const createCustomLogger = (logger: Logger) => {
const { message, meta, error } = params
// Not the expected shape, throw to catch block.
if (!message || isEmpty(meta)) {
//eslint-disable-next-line
throw new Error('Wrong shape')
}
if (error) {
Expand All @@ -224,6 +226,7 @@ const createCustomLogger = (logger: Logger) => {
const { message, meta, error } = params
// Not the expected shape, throw to catch block.
if (!message || isEmpty(meta)) {
//eslint-disable-next-line
throw new Error('Wrong shape')
}
if (error) {
Expand Down
14 changes: 9 additions & 5 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,17 @@ convict.addFormat({
/**
* Verifies that S3 bucket url is a valid url with or without trailing slash
*/
const validateBucketUrl = (
const validateS3BucketUrl = (
val: string,
{
isDev,
hasTrailingSlash,
region,
}: { isDev: boolean; hasTrailingSlash: boolean; region: string },
) => {
/* eslint-disable typesafe/no-throw-sync-func
--------
The convict package expects format validation functions to throw Errors */
if (!validator.isURL(val, { require_tld: !isDev })) {
throw new Error('must be a url')
}
Expand All @@ -59,6 +62,7 @@ const validateBucketUrl = (
if (!isDev && !isRegionCorrect.test(val)) {
throw new Error(`region should be ${region}`)
}
/* eslint-enable typesafe/no-throw-sync-func */
}

// If the default value does not match the format specified, the configuration built from this schema
Expand Down Expand Up @@ -352,27 +356,27 @@ export const loadS3BucketUrlSchema = ({
endPoint: {
doc: 'Endpoint for S3 buckets',
format: (val) =>
validateBucketUrl(val, { isDev, hasTrailingSlash: false, region }),
validateS3BucketUrl(val, { isDev, hasTrailingSlash: false, region }),
default: 'https://s3.ap-southeast-1.amazonaws.com', // NOTE NO TRAILING / AT THE END OF THIS URL!
env: 'AWS_ENDPOINT',
},
attachmentBucketUrl: {
doc:
'Url of attachment S3 bucket derived from S3 endpoint and bucket name',
format: (val) =>
validateBucketUrl(val, { isDev, hasTrailingSlash: true, region }),
validateS3BucketUrl(val, { isDev, hasTrailingSlash: true, region }),
default: null,
},
logoBucketUrl: {
doc: 'Url of logo S3 bucket derived from S3 endpoint and bucket name',
format: (val) =>
validateBucketUrl(val, { isDev, hasTrailingSlash: false, region }),
validateS3BucketUrl(val, { isDev, hasTrailingSlash: false, region }),
default: null,
},
imageBucketUrl: {
doc: 'Url of images S3 bucket derived from S3 endpoint and bucket name',
format: (val) =>
validateBucketUrl(val, { isDev, hasTrailingSlash: false, region }),
validateS3BucketUrl(val, { isDev, hasTrailingSlash: false, region }),
default: null,
},
}
Expand Down